From 50fb67f718966890c189b2a615cb6a08787c9038 Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Tue, 1 Sep 2026 23:12:28 -0700 Subject: [PATCH 001/270] bump tg + TC_MIN_GLOBALS (#38751) bump op + TC min globals --- openpilot/selfdrive/modeld/SConscript | 2 +- tinygrad_repo | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 5a99aa890c..af10467529 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -42,7 +42,7 @@ tg_devices = { # which device to put jit inputs to at runtime CHESTNUT = chestnut_present() if CHESTNUT: - chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_OCCUPANCY_OPT=1' + chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32' # the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it chestnut_lock = File("models/.chestnut.lock").abspath diff --git a/tinygrad_repo b/tinygrad_repo index b87159cee1..f6fc4e3f2c 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit b87159cee1b137c327f901a6aef69f394aa629f6 +Subproject commit f6fc4e3f2c3db5fae1e19cbfbc3ad9fc579a12ae From 06d76bdb24b9f5f3e69120782e8e3eb54a2f4f7e Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:06:41 -0700 Subject: [PATCH 002/270] cabana: imgui port (#38725) * cabana: imgui frontend base infra (ui/) * cabana: line-for-line imgui ports of the Qt widgets and dialogs into ui/ * cabana ui: fixes from the side by side parity test * cabana ui: deqt.md status after the line-for-line ports * cabana ui: fixes from the line-for-line review * cabana ui: parity fixes from the end to end matrix, event driven frame pacing * cabana ui: preserve F1 help formatting * cabana ui: parity fixes from the end to end matrix, event driven frame pacing * cabana ui: deqt.md status * cabana: fix macOS build of imgui frontend - link Security.framework (libusb's darwin backend needs SecTaskCreateFromSelf/SecTaskCopyValueForEntitlement) - drop unused loop counter in SignalView::updateChartState - avoid -Wshadow on Sparkline::size and SignalView::highlight Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: parity round 3 fixes, thread pool and one malloc arena for flat memory * cabana ui: toolbar overflow budget, menu button carets, help overlay colors, signal editor validation * cabana ui: deqt.md status * cabana: let vsync pace the frame loop The loop threw away frames with glfwWaitEventsTimeout() to hit a 30fps (10fps idle) target while glfwSwapBuffers() was already blocking on vsync, so two clocks beat against each other. Input events also reset frameInterval() to 0 for a few frames, so a click swung the cadence from ~33ms to ~13ms and back -- the camera view redraws on the UI's schedule, so that showed up as a visible hitch on every message selection. Poll and draw every iteration and let glfwSwapInterval(1) do the pacing. Co-Authored-By: Claude Opus 5 (1M context) * cabana: fix macOS build of thread pool / malloc arena changes - malloc.h and mallopt(M_ARENA_MAX) are glibc only; guard on __GLIBC__. macOS has a single allocator zone, so there is nothing to cap. - capturing structured bindings in a lambda is a C++20 extension and clang rejects it under -Werror; bind the event range to plain locals. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: chart axis label precision, signal list click on empty space clears the selection * cabana ui: codespell * cabana ui: taller signal rows, system theme detection, full dark palette - signal rows are 1.5x a frame tall with the sparkline sized to the row, so the graphs are readable. The expanded sub-rows keep the Qt height. - "Automatic" resolved to the light theme regardless of the system: Qt gets this from QStyle::standardPalette(), which follows the macOS appearance, so read AppleInterfaceStyle and match it. Other platforms stay light, like standardPalette() does there. - widgets that tested settings.theme == DARK_THEME themselves went light under AUTO_THEME while the style went dark; they now ask isDarkTheme() for the theme applyTheme() actually resolved. - the dark branch only set 10 ImGuiCol_ entries, so buttons, scrollbars, tabs, title bars, separators, tables and grips still came from imgui's stock dark theme. Derive the whole palette from DarkTheme instead. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: drop the dead y axis alignment chain and chart cache stub ImPlot::BeginAlignedPlots aligns the chart y axes, so the hand-rolled alignment protocol ported from Qt no longer feeds anything: align_to was write-only. Remove the whole chain (y_label_width and its measuring loop in draw(), axisYLabelWidthChanged, align_timer, alignCharts), which reduces updatePlotArea(int, bool) to updatePlotArea(). resetChartCache() has been an empty stub since the port (imgui redraws every frame); drop it and its four call sites. Also replace the empty move_icon_rect branch in mousePressEvent with an early return. No behavior change. updateAxisY's "|| y_label_width == 0" guard only fired after a unit change, and y_precision/y_tick_count depend solely on min_y/max_y/tick_count, so the recompute it triggered was a no-op. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: stop the main window from scrolling The host window that holds the dockspace uses zero WindowPadding but the default ItemSpacing, so the dockspace and the status bar below it summed to ItemSpacing.y (5px) more than the viewport, leaving the whole UI scrollable by a few pixels. Reserve the spacing along with the status bar height, and mark the host window NoScrollbar/NoScrollWithMouse: it is pinned to the viewport work area and should never scroll, as QMainWindow does not. Full screen is unaffected, it draws no status bar and reserves no height. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: inset the status bar so its ends are not clipped A borderless BeginChild gets WindowPadding forced to zero, so the status bar text sat flush against both edges and the first glyph of "For Help, Press F1" was cut off. Inset both ends by WindowPadding.x, which lines the text up with the content of the docked panels above it. Right align the cached minutes / FPS label to the captured child width rather than calling GetContentRegionAvail() again after the message text, which only returned the full width because the text had already wrapped the cursor to a new line. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: drop the leftover imgui object census log The block printed an imgui/implot object count to stderr every 10s, which was instrumentation for tracking down a leak, not something the port needs. It was the only user of implot_internal.h in mainwin.cc, so drop that include with it. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: keep the selected row highlighted while hovered Selectable() picks its background as HeaderActive when held, else HeaderHovered when hovered, else Header. The hovered test comes before the selected one, so a selected row under the cursor takes the hover color. Both tables push a transparent HeaderHovered to suppress the hover highlight that QTreeView/QTableView do not have, which also blanked the selection: clicking a row walked press (HeaderActive, visible), release (HeaderHovered, transparent, looks unselected), then mouse off the row (Header, visible), so the selection appeared to flicker until the cursor moved away. Only make HeaderHovered transparent for rows that are not selected. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: draw the binary view hex column in bold and in the text color The Qt delegate never sets a pen for the hex column, so it inherited the black QPainter default and the two halves of a row did not match. Use paletteText(is_message_active), the same color the bit columns use, so both halves read as one row and dim together when the message goes inactive. JetBrains Mono ships no bold variant and imgui has no embolden option, so give drawStaticText a bold flag that redraws the glyphs a fraction of a pixel to the right. That keeps the monospace advance, which switching to the proportional bold face would not. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: highlight the whole row in the speed menu MenuItem spans from the cursor to the right edge, so the Indent() that made room for the radio bullet also pushed the highlight in by one FontSize and left the bullet column outside it. QMenu highlights the whole item, indicator included. Draw each row as one full width Selectable with the bullet and the label inside it. Every row declares the same width, so the popup is as wide as the widest entry and the highlights reach both edges; a label-less Selectable sized zero would declare no layout width and collapse the auto-sizing popup. Selectable auto-closes its parent popup like MenuItem does, so clicking still works in the dropdown and in the Speed submenu of the toolbar overflow menu, which shares this function. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: fix help menu overlay, Ctrl+F11, time label clipping, toolbar overflow and tool table parity * cabana ui: click to edit signal cells, light theme selection, logs cell selection and filter fixes * cabana ui: chart x label margin, zoom context menu items, video splitter collapse, signal editor close * cabana ui: signal editor lifetime and close, logs cell hover, tool table headers * cabana ui: branch indicator clicks, editor swallows shortcut and button clicks, selection colors * cabana ui: float dock windows into real OS windows Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: toolbar menu arrow spacing, only open the editor on editable cells Co-Authored-By: Claude Opus 5 (1M context) * op: add cabana2 to run the imgui cabana Co-Authored-By: Claude Opus 5 (1M context) * cabana: cabana2 launcher script builds _cabana_ui before running it Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: only float the dock panels into their own OS windows Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: open the video pane at the camera's natural aspect ratio Co-Authored-By: Claude Opus 5 (1M context) * cabana: default fps to 30 * cabana ui: fusion palette, shared fusion slider, chart grid colors * cabana ui: smaller checkbox indicator, closer to the Fusion size * cabana ui: video toolbar height and item centering, separator extent and menu arrow like Fusion * cabana ui: message and signal row heights match Qt * cabana ui: timeline slider height, groove and stacking match the Qt video layout * cabana ui: fix crash opening a live stream, the video pane default height read the missing camera widget * cabana: drop the FPS setting, the streams update the UI at a fixed 30 fps * cabana ui: video and charts pane run edge to edge with a uniform toolbar margin like the Qt frames * cabana ui: binary view M/L marker size and timeline event fills match Qt * cabana ui: signal view keeps its sparklines while filtering, number label size matches Qt * cabana ui: no modal dim fade, QDialog does not animate in * cabana ui: collapse all is an auto-raise tool button with the Qt icon size * cabana ui: dialogs and tool windows open as real OS windows like QDialog * cabana ui: hold back a focus loss while a mouse button is down so tearing a panel off does not abort the drag and create its window twice * cabana ui: speed menu rows pad the label on the right like QMenu * cabana ui: raise the dark theme contrast, the Darcula grays all sat on top of the background the chart y axis guides are opaque and 2 px in dark mode, and text, outlines, separators, table borders, scrollbar and slider grabs move away from the window and base grays. light theme is unchanged. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: simplify comments, drop the Qt references and the ones that only restate the code * cabana ui: keep the sparkline antialiased, thin peaks sparkled as the window slid Qt dropped antialiasing above 500 points because it rasterized into a pixmap. drawing straight into the frame, an aliased 1 px segment between two columns rounds into one of them and the rounding flips as the window advances, so the peaks of the dense signals popped between columns. updateState runs at STREAM_UPDATE_FPS, which at a 30s range moves the curve well under a pixel per update, so the flip is all that is visible. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: anchor the sparkline to the time window, it jittered sideways as samples expired x was measured from the first sample still inside the window, which changes whenever the oldest one falls out, so every update slid the whole curve sideways by the sample spacing. measuring from the start of the window pins the right edge and lets the samples flow left with the clock. the density heuristic wanted the data span, which is no longer the x of the last point, and the flat line started at x 0 instead of at its first sample. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: scroll the sparkline at the frame rate, it stepped at the message rate the window ended at the last message of the id and updateState only runs when one arrives, so a slow message held the sparkline still for several frames and then jumped it. the window now ends at the playback clock, and draw() slides the rendered polyline by the time that has passed since it was built, clipped to the cell. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: keep the signal value on the right of the column before there is a sparkline it was drawn left aligned from the start of the column, so the value sat next to the name until the first samples arrived and then jumped across the row. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: read a little past the sparkline window so the oldest points slide out of it a sample used to disappear the moment it aged out, which on a fixed rate signal is a point popping off the left edge. the query reaches back a bit further and the clip rect hides it instead. the lead in must not move the scale, so it is left out of the min and max. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: use the macOS fullscreen space, taking over the monitor rendered at the old size glfwSetWindowMonitor switched the display mode and returned a drawable at that mode, so the ui was rendered at the windowed size and stretched over the screen. NSWindow toggleFullScreen keeps the backing scale. the green button goes through the same space, so the state is read back from the style mask rather than tracked. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: let the signal value column shrink again, it held the widest value ever seen max_value_width only ever grew, so one long value left the sparklines ending well short of the text for the rest of the message. Track the current widest value instead, growing on demand and giving room back once it is a couple of characters too wide, which keeps the width from jittering per sample. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: make the signal rows a quarter taller so the sparklines read better Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: lift the binary view heatmap alphas in dark mode, the tints washed out The alphas were tuned against a white background; over the dark base the same values left the cells barely colored. The floor comes up and the ramp gets a gamma in dark mode only, which keeps the flip counts in the same order. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: saturate the sparkline color in light mode, the pastel lines vanished Signal colors are low saturation and high value, which is aimed at dark fills; a 1 px line of one of them on white is hard to see. Draw the sparkline with twice the saturation and less value when the theme is light. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: give the docked panels a minimum width, the dividers ran over content Co-Authored-By: Claude Opus 5 (1M context) * replay: capture the downloader's stderr so the progress lines reach the handler file_downloader.py writes PROGRESS:: to stderr while it streams a file, but runPython() only piped stdout and left stderr attached to the parent, so the progress handler only ever fired on failure and cabana downloaded a segment with no progress bar. Pipe stderr as well, turn the PROGRESS lines into handler calls, and pass everything else through to the parent's stderr. Co-Authored-By: Claude Opus 5 (1M context) * replay: read the downloader's stderr on a thread, the two fd select was overkill getline() handles the line splitting, partial lines and EOF, so the manual splitter, the second drain loop and the multiplexing bookkeeping go away and the stdout loop goes back to what it was. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: one toolButton helper in imgui_util.h * cabana ui: one toImU32/toImVec4/paletteBrightText in imgui_util.h * cabana ui: string helpers live in utils/strings.h * cabana ui: share the fusion slider handle from imgui_util.h * cabana ui: style.cc declarations move to imgui_util.h * cabana ui: validatedInput and the qt validators move to imgui_util.h * cabana ui: viewSelectable helper for the item view rows * cabana ui: drop the comboBox pass-through * cabana ui: tabbar moves to its own file, ready for the charts port * cabana ui: find signal searches on the main thread again, deferred one frame like QTimer::singleShot(0) * cabana ui: find signal computes its button and label state from the model instead of caching it * cabana ui: share the queued popup owner protocol between the message box and the file dialog * cabana ui: one beginDialog helper for the three centered modals, with the reopen recovery * cabana ui: tool dialogs share the title, begin/end and escape handling, and own their connections * cabana ui: comboBox helper replaces the hand rolled zero separated item buffers * cabana ui: drop the unused dialog isOpen accessors * cabana ui: one validatedText helper for the ip, double and int line edits * cabana ui: route info reads the route segments instead of caching a row copy * cabana ui: settings drops the phantom fps label, the constant spin flags and the raw log path buffer * cabana ui: find similar bits drops the no-op find button disable and the address round trip * cabana ui: message box warning overload without the empty detailed text * cabana ui: help overlay only collects rects while it is up, skips torn off panels, and the fingerprint compare is ordered * cabana ui: drop the glfw callbacks that only forward to the imgui backend, which already installs them * cabana ui: elided label renders with RenderTextEllipsis instead of a paint time cache * cabana ui: one save loop, inline cached minutes label, and drop the dead help parser branches * cabana ui: signal cell bands are built from the two corner notches instead of a region sweep * cabana ui: binary view items go through itemAt and updateItem assigns unconditionally * cabana ui: message bytes read the cell padding inline and colored bytes keep the qt default pen * cabana ui: detail widget drops createToolBar, the welcome widget flag and the unused whatsThis * cabana ui: messages widget owns its view, header and model directly, fixed size filter editors, from_chars range parsing * cabana ui: colored hex bytes keep the row pen, the qt delegate does not reset it per cell * cabana ui: closeEditor drops the queued commit, its item is about to be deleted * cabana ui: rowsInserted carries the insert position so the selected row follows its message * cabana ui: the log filter and the message name editors use the shared validators * cabana ui: the log header sizing and painting are free functions, no HeaderView object * cabana ui: the visible row range is the change detector, no mirrored scroll position * cabana ui: the line editor takes the focus flag as an argument and lets InputText revert on escape * cabana ui: signal view drops the unreachable column parameters, unused item values and repeated sparkline tests * cabana ui: rows outside the tree viewport are measured but not painted * cabana ui: one toolbar item table, playback state read in draw() * cabana ui: draw the stored thumbnail scaled by AddImage, no cpu prescaled copy * cabana ui: one radioMenuItem helper for the speed and series type menus * cabana ui: drop dead camera members, hoist the jpeg colour space branch, decode qlog thumbnails on the thread pool * cabana ui: stop the vipc thread when the splitter collapses the video pane * cabana ui: the toolbar strings and enabled state are computed in drawToolBar * cabana ui: chartswidget uses the shared tabbar widget * cabana ui: the chart header layout is recomputed in resizeEvent, no updateTitle * cabana ui: updateLayout has no force flag * cabana ui: one condition hides the value tip * cabana ui: signal selector uses the shared dialog buttons and row indexes * cabana ui: the chart tile geometry is one Layout struct saved and restored by drawGhost * cabana ui: drop the empty theme branch, the unused size hint and the charts container indirections * cabana ui: the chart holds its tip label by value * cabana ui: one toolbar item list for both toolbar groups * cabana ui: the sparkline polyline is emitted through the draw list path * cabana ui: the tab bar latches a programmatic selection and defers the close request past EndTabBar * cabana ui: validatedText refuses an invalid edit inside the imgui buffer and the ip field keeps its char filter * cabana ui: drop the ambiguous three argument warning overload * cabana ui: combobox reports a real change, menu rows span the popup, one popup protocol for the dialogs * cabana ui: the startup stream is owned by the window until the first frame opens it * cabana ui: the slider toolbar index has an out of range sentinel * cabana ui: the find signal search runs on the frame after the pending state is painted * cabana ui: the log clears its selection when the rows are dropped and cells are identified by their message * cabana ui: the open editor survives a scroll, collapse all commits it and escape in the size editor does not * cabana ui: the header display order is filled with iota * cabana ui: qlog thumbnails decode on their own threads, the hover thumbnail is mipmapped and the time tooltip appears on the first toggle * cabana ui: a collapsed video dock stops the vipc thread * cabana ui: one TOOLBAR_ITEM_SPACING, the signal view used the QCommonStyle 4 instead of Fusion's 1 * cabana ui: sparkline includes imgui_util.h for isDarkTheme * cabana ui: imgui_util.h becomes util.h and util.cc * cabana: delete deqt.md * cabana ui: panel min width, matching video panel padding, centered tabs The dock panels stop shrinking at the width where the signal view tool bar squishes instead of at a hardcoded 440. The video/charts panel keeps the standard window padding so it lines up with the other panels, and the Msg/Logs tabs are centered in their bar. The cached minutes label is gone from the status bar. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: persist imgui state in cabana.json and migrate the Qt frontend's layout blobs * cabana ui: 2px sparkline stroke a 1px antialiased line is all fringe and no solid core, so the curve faded and shimmered as the window scrolled. Co-Authored-By: Claude Opus 5 (1M context) * cabana: drop the Automatic color theme it only did anything on macOS: the Qt frontend fell through to standardPalette() for both Automatic and Light, so the two were identical everywhere else. LIGHT_THEME and DARK_THEME keep their values so a persisted dark theme still reads as dark; the old Automatic 0 falls back to light on load. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: window color between the binary and signal views, fixed split the binary view sits at its size hint and no longer resizes, and the gap below it takes the window color instead of leaving a white seam. also border the video and chart panels like the signal view. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: bigger tab bar scroll buttons imgui's built in scroll arrows are fixed at FontSize - 2 wide with the glyph drawn in a FontSize box, so they are small and sit off center. draw chevron buttons at the frame height instead, spaced and disabled at the ends of the scroll range like the rest of the tool buttons. Co-Authored-By: Claude Opus 5 (1M context) * cabana ui: 1.5px sparkline stroke * cabana ui: stable sparkline scrolling, one sample per column, aliased spikes Scroll the polyline by whole physical pixels with each sample's subpixel phase fixed to its timestamp, thin to one sample per pixel column, and draw near-vertical segments aliased so spikes stay crisp while smooth curves keep antialiasing. Co-Authored-By: Claude Fable 5.1 * cabana ui: fix spelling Co-Authored-By: Claude Fable 5.1 * cabana ui: size the value column for the widest value a signal can produce Sizing it to the values in the last message moved the sparklines every time a value changed length. Co-Authored-By: Claude Fable 5.1 * cabana ui: center the tab bar scroll chevrons and give the buttons a frame The icon font glyph sits off center in its padded advance, so the chevron is drawn in the button rect. The buttons take the theme's button background and border like the tool buttons next to them. Co-Authored-By: Claude Fable 5.1 * cabana ui: darker grid lines between table cells Co-Authored-By: Claude Fable 5.1 * cabana ui: the gap between the video and the charts matches the side padding Co-Authored-By: Claude Fable 5.1 * cabana ui: plus-square icon for the new chart button, like the remove all button Co-Authored-By: Claude Fable 5.1 * cabana ui: video toolbar buttons as tall as the charts toolbar buttons The buttons carried 10 px of vertical padding, so the hover rect was much taller than the glyph and the row looked off center. Co-Authored-By: Claude Fable 5.1 * cabana ui: the time display in the mono font at the ui font size With proportional digits the time changed width as it ticked and the items after it moved. Co-Authored-By: Claude Fable 5.1 * cabana ui: closing a floated side panel docks it back, no hide tab bar button, the charts container never scrolls Co-Authored-By: Claude Fable 5.1 * cabana ui: the message tabs use the TabBar widget, chevron scroll buttons for both tab bars The chevron scroll buttons move into a scrollable tab bar widget that both use. The wheel scrolls the tabs, both tab bars get Close Other Tabs, and the Msg tab is labeled Messages. Co-Authored-By: Claude Fable 5.1 * cabana ui: deliver a focus loss right away on macOS, holding it back swallowed the first click in a popup Co-Authored-By: Claude Fable 5.1 * cabana ui: draw the remote routes dialog at the modal's level, opened inside the tab's child window it never appeared Co-Authored-By: Claude Fable 5 * cabana ui: --no-cache turns off the local route file cache, same as replay Co-Authored-By: Claude Fable 5 * cabana: parse merged segments on replay's merge thread, the parse on the main thread dropped frames on every merge while downloading Co-Authored-By: Claude Fable 5 * cabana ui: Esc leaves full screen on all platforms Co-Authored-By: Claude Fable 5 * cabana ui: an app bundle on macOS so the menu bar and the dock show Cabana A bare binary shows its file name there. The build wraps _cabana_ui in a minimal Cabana.app with an Info.plist and the launcher runs that. Co-Authored-By: Claude Fable 5.1 * Revert "cabana ui: an app bundle on macOS so the menu bar and the dock show Cabana" This reverts commit 1a45540125847ca46fcd4d7ac0522918e315057d. * cabana ui: the app menu on macOS says Cabana A bare binary gets an info dictionary with its file name in it, which glfw reads for the app menu. The name is set there before glfw brings up cocoa. Co-Authored-By: Claude Fable 5.1 * cabana ui: macapp moves into ui/util Co-Authored-By: Claude Fable 5.1 * cabana ui: setMacAppName lives in ui/util.cc Co-Authored-By: Claude Fable 5.1 * cabana ui: apply the code review Bugs: an empty decode batch dereferenced vals.front(), the chart x comparator took a float, reserve used capacity(), the bit text went black while resizing a signal, byte cells under-sized non-multiple-of-8 payloads, the CAN speed lookup read the data speed table, the export tooltip never showed while disabled, and the thumbnail parser and the signal search joined ad-hoc threads on the render thread. Both now run off the pool; the search fans out over it from its own thread. Shared helpers in ui/util: the tool bar with its overflow menu, the menu button, modal dialog setup, Escape inside dialogButtons, drawText, drawElidedText, color markers, alignRight, clearableInput, disabledItemTooltip, inputTextMultiline, tableHeadersRow with the right click, withAlpha, boldFont, the Cocoa full screen glue, the messages panel id. utils gets hexByte, guarded and nonEmptyDBCFiles; threadpool gets parallelFor. The help overlay leaves mainwin.cc for its own file, the stream teardown is one releaseStream(), the charts own their views through unique_ptr, the find-similar-bits message list follows the source bus, the clipboard copies every open DBC, and the Qt vestiges (event names, model API, protected sections, restating comments, stale TODOs, mixed member naming) are gone. Co-Authored-By: Claude Fable 5.1 * cabana ui: collapse the model/view/delegate split The Qt-era model, view and delegate classes were ported one to one, but imgui draws a table in one function. MessageView, MessageViewHeader and MessageListModel's string API fold into MessagesWidget over a plain MessageList; HistoryLogModel folds into LogsWidget; BinaryViewModel and BinaryItemDelegate fold into BinaryView; SignalItemDelegate folds into SignalView, and SignalModel keeps only the item tree with three predicates instead of the flags bitmask; FindSignalModel becomes SignalSearch. The byte cell painter is a set of free functions shared by the two tables. Co-Authored-By: Claude Fable 5.1 * cabana ui: load the route behind the window The route file listing is an HTTPS round trip to the comma API and took the whole first second of startup before the window existed. The replay branch of main() now hands run() a loader that a worker executes after the first frame; the window maps in ~0.1 s instead of ~1 s. On a failed load the app stays open in the No Stream state with the error box instead of exiting before a window ever appeared. * cabana ui: fix the live stream video controls layout * cabana ui: only copy dbc to clipboard with a single file * cabana ui: report route loading failures instead of hanging * cabana: launch the imgui frontend by default, --legacy for Qt --------- Co-authored-by: Trey Moen Co-authored-by: Claude Opus 5 (1M context) --- openpilot/tools/cabana/.gitignore | 1 + openpilot/tools/cabana/SConscript | 79 +- openpilot/tools/cabana/cabana | 29 +- openpilot/tools/cabana/core/color.h | 1 - openpilot/tools/cabana/core/settings.h | 4 +- openpilot/tools/cabana/dbc/dbcmanager.cc | 11 +- openpilot/tools/cabana/dbc/dbcmanager.h | 1 + openpilot/tools/cabana/deqt.md | 53 - openpilot/tools/cabana/mainwin.cc | 2 +- openpilot/tools/cabana/messageswidget.cc | 2 +- openpilot/tools/cabana/settings.cc | 5 +- openpilot/tools/cabana/settings.h | 4 + openpilot/tools/cabana/settingsdialog.cc | 12 +- openpilot/tools/cabana/settingsdialog.h | 1 - .../tools/cabana/streams/abstractstream.cc | 6 +- .../tools/cabana/streams/abstractstream.h | 1 + openpilot/tools/cabana/streams/livestream.cc | 4 +- openpilot/tools/cabana/streams/livestream.h | 1 - .../tools/cabana/streams/replaystream.cc | 13 +- openpilot/tools/cabana/tests/test_cabana.cc | 55 + .../tools/cabana/tests/test_cabana_ui.py | 13 + openpilot/tools/cabana/ui/app.cc | 212 ++++ openpilot/tools/cabana/ui/app.h | 23 + openpilot/tools/cabana/ui/chart/chart.cc | 790 ++++++++++++++ openpilot/tools/cabana/ui/chart/chart.h | 142 +++ .../tools/cabana/ui/chart/chartswidget.cc | 674 ++++++++++++ .../tools/cabana/ui/chart/chartswidget.h | 168 +++ .../tools/cabana/ui/chart/signalselector.cc | 155 +++ .../tools/cabana/ui/chart/signalselector.h | 46 + openpilot/tools/cabana/ui/chart/sparkline.cc | 177 ++++ openpilot/tools/cabana/ui/chart/sparkline.h | 34 + openpilot/tools/cabana/ui/chart/tiplabel.cc | 69 ++ openpilot/tools/cabana/ui/chart/tiplabel.h | 35 + .../tools/cabana/ui/dialogs/filedialog.cc | 222 ++++ .../tools/cabana/ui/dialogs/filedialog.h | 19 + .../tools/cabana/ui/dialogs/messagebox.cc | 87 ++ .../tools/cabana/ui/dialogs/messagebox.h | 19 + .../tools/cabana/ui/dialogs/routesdialog.cc | 124 +++ .../tools/cabana/ui/dialogs/routesdialog.h | 45 + .../tools/cabana/ui/dialogs/settingsdialog.cc | 108 ++ .../tools/cabana/ui/dialogs/settingsdialog.h | 24 + .../tools/cabana/ui/dialogs/streamselector.cc | 322 ++++++ .../tools/cabana/ui/dialogs/streamselector.h | 106 ++ openpilot/tools/cabana/ui/helpoverlay.cc | 196 ++++ openpilot/tools/cabana/ui/helpoverlay.h | 24 + openpilot/tools/cabana/ui/icons.h | 40 + openpilot/tools/cabana/ui/inistate.cc | 138 +++ openpilot/tools/cabana/ui/inistate.h | 27 + openpilot/tools/cabana/ui/main.cc | 202 ++++ openpilot/tools/cabana/ui/mainwin.cc | 929 +++++++++++++++++ openpilot/tools/cabana/ui/mainwin.h | 138 +++ openpilot/tools/cabana/ui/qtstate.cc | 171 +++ openpilot/tools/cabana/ui/qtstate.h | 38 + openpilot/tools/cabana/ui/style.cc | 280 +++++ openpilot/tools/cabana/ui/threadpool.h | 81 ++ openpilot/tools/cabana/ui/tools/findsignal.cc | 325 ++++++ openpilot/tools/cabana/ui/tools/findsignal.h | 59 ++ .../tools/cabana/ui/tools/findsimilarbits.cc | 178 ++++ .../tools/cabana/ui/tools/findsimilarbits.h | 40 + openpilot/tools/cabana/ui/tools/routeinfo.cc | 50 + openpilot/tools/cabana/ui/tools/routeinfo.h | 14 + openpilot/tools/cabana/ui/tools/tooldialog.h | 52 + openpilot/tools/cabana/ui/util.cc | 466 +++++++++ openpilot/tools/cabana/ui/util.h | 201 ++++ .../tools/cabana/ui/widgets/binaryview.cc | 548 ++++++++++ .../tools/cabana/ui/widgets/binaryview.h | 98 ++ .../tools/cabana/ui/widgets/cameraview.cc | 176 ++++ .../tools/cabana/ui/widgets/cameraview.h | 88 ++ .../tools/cabana/ui/widgets/detailwidget.cc | 439 ++++++++ .../tools/cabana/ui/widgets/detailwidget.h | 112 ++ .../tools/cabana/ui/widgets/historylog.cc | 307 ++++++ .../tools/cabana/ui/widgets/historylog.h | 59 ++ .../tools/cabana/ui/widgets/messagebytes.cc | 60 ++ .../tools/cabana/ui/widgets/messagebytes.h | 20 + .../tools/cabana/ui/widgets/messageswidget.cc | 512 +++++++++ .../tools/cabana/ui/widgets/messageswidget.h | 93 ++ .../cabana/ui/widgets/scrollabletabbar.cc | 88 ++ .../cabana/ui/widgets/scrollabletabbar.h | 8 + .../tools/cabana/ui/widgets/signalview.cc | 975 ++++++++++++++++++ .../tools/cabana/ui/widgets/signalview.h | 201 ++++ openpilot/tools/cabana/ui/widgets/tabbar.cc | 92 ++ openpilot/tools/cabana/ui/widgets/tabbar.h | 45 + .../tools/cabana/ui/widgets/videowidget.cc | 612 +++++++++++ .../tools/cabana/ui/widgets/videowidget.h | 121 +++ openpilot/tools/cabana/utils/strings.h | 76 ++ openpilot/tools/cabana/utils/util.h | 9 + tools/op.sh | 2 +- 87 files changed, 12176 insertions(+), 113 deletions(-) delete mode 100644 openpilot/tools/cabana/deqt.md create mode 100644 openpilot/tools/cabana/tests/test_cabana_ui.py create mode 100644 openpilot/tools/cabana/ui/app.cc create mode 100644 openpilot/tools/cabana/ui/app.h create mode 100644 openpilot/tools/cabana/ui/chart/chart.cc create mode 100644 openpilot/tools/cabana/ui/chart/chart.h create mode 100644 openpilot/tools/cabana/ui/chart/chartswidget.cc create mode 100644 openpilot/tools/cabana/ui/chart/chartswidget.h create mode 100644 openpilot/tools/cabana/ui/chart/signalselector.cc create mode 100644 openpilot/tools/cabana/ui/chart/signalselector.h create mode 100644 openpilot/tools/cabana/ui/chart/sparkline.cc create mode 100644 openpilot/tools/cabana/ui/chart/sparkline.h create mode 100644 openpilot/tools/cabana/ui/chart/tiplabel.cc create mode 100644 openpilot/tools/cabana/ui/chart/tiplabel.h create mode 100644 openpilot/tools/cabana/ui/dialogs/filedialog.cc create mode 100644 openpilot/tools/cabana/ui/dialogs/filedialog.h create mode 100644 openpilot/tools/cabana/ui/dialogs/messagebox.cc create mode 100644 openpilot/tools/cabana/ui/dialogs/messagebox.h create mode 100644 openpilot/tools/cabana/ui/dialogs/routesdialog.cc create mode 100644 openpilot/tools/cabana/ui/dialogs/routesdialog.h create mode 100644 openpilot/tools/cabana/ui/dialogs/settingsdialog.cc create mode 100644 openpilot/tools/cabana/ui/dialogs/settingsdialog.h create mode 100644 openpilot/tools/cabana/ui/dialogs/streamselector.cc create mode 100644 openpilot/tools/cabana/ui/dialogs/streamselector.h create mode 100644 openpilot/tools/cabana/ui/helpoverlay.cc create mode 100644 openpilot/tools/cabana/ui/helpoverlay.h create mode 100644 openpilot/tools/cabana/ui/icons.h create mode 100644 openpilot/tools/cabana/ui/inistate.cc create mode 100644 openpilot/tools/cabana/ui/inistate.h create mode 100644 openpilot/tools/cabana/ui/main.cc create mode 100644 openpilot/tools/cabana/ui/mainwin.cc create mode 100644 openpilot/tools/cabana/ui/mainwin.h create mode 100644 openpilot/tools/cabana/ui/qtstate.cc create mode 100644 openpilot/tools/cabana/ui/qtstate.h create mode 100644 openpilot/tools/cabana/ui/style.cc create mode 100644 openpilot/tools/cabana/ui/threadpool.h create mode 100644 openpilot/tools/cabana/ui/tools/findsignal.cc create mode 100644 openpilot/tools/cabana/ui/tools/findsignal.h create mode 100644 openpilot/tools/cabana/ui/tools/findsimilarbits.cc create mode 100644 openpilot/tools/cabana/ui/tools/findsimilarbits.h create mode 100644 openpilot/tools/cabana/ui/tools/routeinfo.cc create mode 100644 openpilot/tools/cabana/ui/tools/routeinfo.h create mode 100644 openpilot/tools/cabana/ui/tools/tooldialog.h create mode 100644 openpilot/tools/cabana/ui/util.cc create mode 100644 openpilot/tools/cabana/ui/util.h create mode 100644 openpilot/tools/cabana/ui/widgets/binaryview.cc create mode 100644 openpilot/tools/cabana/ui/widgets/binaryview.h create mode 100644 openpilot/tools/cabana/ui/widgets/cameraview.cc create mode 100644 openpilot/tools/cabana/ui/widgets/cameraview.h create mode 100644 openpilot/tools/cabana/ui/widgets/detailwidget.cc create mode 100644 openpilot/tools/cabana/ui/widgets/detailwidget.h create mode 100644 openpilot/tools/cabana/ui/widgets/historylog.cc create mode 100644 openpilot/tools/cabana/ui/widgets/historylog.h create mode 100644 openpilot/tools/cabana/ui/widgets/messagebytes.cc create mode 100644 openpilot/tools/cabana/ui/widgets/messagebytes.h create mode 100644 openpilot/tools/cabana/ui/widgets/messageswidget.cc create mode 100644 openpilot/tools/cabana/ui/widgets/messageswidget.h create mode 100644 openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc create mode 100644 openpilot/tools/cabana/ui/widgets/scrollabletabbar.h create mode 100644 openpilot/tools/cabana/ui/widgets/signalview.cc create mode 100644 openpilot/tools/cabana/ui/widgets/signalview.h create mode 100644 openpilot/tools/cabana/ui/widgets/tabbar.cc create mode 100644 openpilot/tools/cabana/ui/widgets/tabbar.h create mode 100644 openpilot/tools/cabana/ui/widgets/videowidget.cc create mode 100644 openpilot/tools/cabana/ui/widgets/videowidget.h diff --git a/openpilot/tools/cabana/.gitignore b/openpilot/tools/cabana/.gitignore index 7f9ac0fde0..c5c752ba9c 100644 --- a/openpilot/tools/cabana/.gitignore +++ b/openpilot/tools/cabana/.gitignore @@ -6,6 +6,7 @@ assets.cc bootstrap_icons.cc _cabana +_cabana_ui dbc/car_fingerprint_to_dbc.json tests/test_cabana tests/test_dbc_core diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index 63b725a759..a324e09d67 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -3,10 +3,59 @@ import os import shutil import bootstrap_icons +import imgui import libusb +from openpilot.common.basedir import BASEDIR Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib', 'ffmpeg_libs') +opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (env.Dir("../../../opendbc_repo/opendbc/dbc").abspath) + +# 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 + +bootstrap_icons_src = env.Command('assets/bootstrap_icons.cc', str(bootstrap_icons.SVG_PATH), build_bootstrap_icons_src) + +# Qt-free sources shared by the imgui frontend and the core test. Compiled in the base env, +# so the object names must not collide with the Qt build of the same sources. +core_srcs = ['streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', + 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'utils/export.cc', 'utils/util.cc', 'utils/strings.cc', + 'commands.cc', 'settings.cc', 'routes.cc', 'panda.cc'] +if arch != "Darwin": + core_srcs += ['streams/socketcanstream.cc'] + +# imgui frontend (tools/cabana/ui), no Qt +ui_env = env.Clone() +ui_env['CPPPATH'] += [imgui.INCLUDE_DIR, libusb.INCLUDE_DIR] +ui_env['LIBPATH'] += [imgui.MESA_DIR, libusb.LIB_DIR] +ui_env['CXXFLAGS'] += [ + opendbc_path, + "-DGLFW_INCLUDE_NONE", + '-DCABANA_FONTS_DIR=\'"%s"\'' % os.path.join(os.path.realpath(BASEDIR), "openpilot", "selfdrive", "assets", "fonts"), + '-DBOOTSTRAP_ICONS_TTF=\'"%s"\'' % bootstrap_icons.TTF_PATH, +] +ui_objs = [ui_env.Object('ui/obj/' + src.replace('/', '_')[:-3], src) for src in core_srcs] +ui_objs += [ui_env.Object('ui/obj/bootstrap_icons', bootstrap_icons_src)] +ui_objs += ui_env.Glob('ui/*.cc') + ui_env.Glob('ui/widgets/*.cc') + ui_env.Glob('ui/dialogs/*.cc') + ui_env.Glob('ui/chart/*.cc') + ui_env.Glob('ui/tools/*.cc') +ui_libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a")] + \ + ffmpeg_libs + ['zstd', 'm', 'pthread', 'usb-1.0'] +if arch == "Darwin": + ui_env['FRAMEWORKS'] = ['OpenGL', 'Cocoa', 'IOKit', 'CoreFoundation', 'CoreVideo', 'CoreMedia', 'Security', 'VideoToolbox'] +else: + ui_libs += ['GL', 'dl'] +cabana_ui = ui_env.Program('_cabana_ui', ui_objs, LIBS=ui_libs) + # Detect Qt - skip build if not available if arch == "Darwin": try: @@ -74,38 +123,18 @@ cabana_env['CPPPATH'] += [libusb.INCLUDE_DIR] cabana_env['LIBPATH'] += [libusb.LIB_DIR] cabana_libs = [cereal, messaging, visionipc, replay_lib] + ffmpeg_libs + ['usb-1.0'] + base_libs -opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("../../../opendbc_repo/opendbc/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 - -bootstrap_icons_src = cabana_env.Command('assets/bootstrap_icons.cc', str(bootstrap_icons.SVG_PATH), build_bootstrap_icons_src) - # build assets assets = "assets/assets.cc" cabana_env.Command(assets, "assets/assets.qrc", f"rcc $SOURCES -o $TARGET") cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"])) -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', - 'routesdialog.cc', 'routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', - 'utils/export.cc', 'utils/util.cc', 'utils/qtutil.cc', 'utils/strings.cc', 'utils/elidedlabel.cc', +cabana_srcs = ['mainwin.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', 'routesdialog.cc', + 'utils/qtutil.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', 'settingsdialog.cc', 'panda.cc', - 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] -if arch != "Darwin": - cabana_srcs += ['streams/socketcanstream.cc'] + 'messageswidget.cc', 'streamselector.cc', 'settingsdialog.cc', + 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] + core_srcs 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) @@ -123,6 +152,7 @@ if GetOption('extras'): dbc_core_test_env.Object('tests/dbc_core_util', 'utils/util.cc'), dbc_core_test_env.Object('tests/dbc_core_icons', bootstrap_icons_src), dbc_core_test_env.Object('tests/dbc_core_routes', 'routes.cc'), + dbc_core_test_env.Object('tests/dbc_core_qtstate', 'ui/qtstate.cc'), ] dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects, LIBS=[replay_lib, common]) @@ -131,3 +161,4 @@ generate_dbc = cabana_env.Command('#' + output_json_file, ['dbc/generate_dbc_json.py'], "python3 openpilot/tools/cabana/dbc/generate_dbc_json.py --out " + output_json_file) cabana_env.Depends(generate_dbc, ["#openpilot/common", '#opendbc_repo', "#openpilot/cereal", "#msgq_repo"]) +ui_env.Depends(cabana_ui, generate_dbc) diff --git a/openpilot/tools/cabana/cabana b/openpilot/tools/cabana/cabana index db613b5391..fb995a05c2 100755 --- a/openpilot/tools/cabana/cabana +++ b/openpilot/tools/cabana/cabana @@ -25,14 +25,29 @@ install_qt() { fi } -# Install Qt if not found -if ! command -v qmake &> /dev/null; then - echo "Qt not found, installing dependencies..." - install_qt +# --legacy runs the old Qt cabana until it is deprecated +LEGACY=0 +ARGS=() +for arg in "$@"; do + if [[ "$arg" == "--legacy" ]]; then + LEGACY=1 + else + ARGS+=("$arg") + fi +done + +if [[ $LEGACY -eq 1 ]]; then + # Install Qt if not found + if ! command -v qmake &> /dev/null; then + echo "Qt not found, installing dependencies..." + install_qt + fi + TARGET="_cabana" +else + TARGET="_cabana_ui" fi -# Build _cabana cd "$ROOT" -scons -u openpilot/tools/cabana/_cabana openpilot/cereal/messaging/bridge +scons -u "openpilot/tools/cabana/$TARGET" openpilot/cereal/messaging/bridge -exec "$DIR/_cabana" "$@" +exec "$DIR/$TARGET" "${ARGS[@]}" diff --git a/openpilot/tools/cabana/core/color.h b/openpilot/tools/cabana/core/color.h index c29704dd44..47e7370c5e 100644 --- a/openpilot/tools/cabana/core/color.h +++ b/openpilot/tools/cabana/core/color.h @@ -60,7 +60,6 @@ struct CabanaColor { 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; diff --git a/openpilot/tools/cabana/core/settings.h b/openpilot/tools/cabana/core/settings.h index cad152de92..1b9e2bcfc4 100644 --- a/openpilot/tools/cabana/core/settings.h +++ b/openpilot/tools/cabana/core/settings.h @@ -5,18 +5,18 @@ constexpr int LIGHT_THEME = 1; constexpr int DARK_THEME = 2; +constexpr int STREAM_UPDATE_FPS = 30; // rate the streams publish message updates to the UI 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 theme = LIGHT_THEME; int sparkline_range = 15; bool multiple_lines_hex = false; bool log_livestream = true; diff --git a/openpilot/tools/cabana/dbc/dbcmanager.cc b/openpilot/tools/cabana/dbc/dbcmanager.cc index 551619ea82..5170ab74d0 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.cc +++ b/openpilot/tools/cabana/dbc/dbcmanager.cc @@ -141,8 +141,15 @@ std::vector DBCManager::signalNames() { } int DBCManager::nonEmptyDBCCount() { - auto files = allDBCFiles(); - return std::count_if(files.cbegin(), files.cend(), [](auto &f) { return !f->isEmpty(); }); + return nonEmptyDBCFiles().size(); +} + +std::vector DBCManager::nonEmptyDBCFiles() { + std::vector files; + for (auto f : allDBCFiles()) { + if (!f->isEmpty()) files.push_back(f); + } + return files; } DBCFile *DBCManager::findDBCFile(const uint8_t source) { diff --git a/openpilot/tools/cabana/dbc/dbcmanager.h b/openpilot/tools/cabana/dbc/dbcmanager.h index 6bc86a8167..a729986f4a 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.h +++ b/openpilot/tools/cabana/dbc/dbcmanager.h @@ -39,6 +39,7 @@ public: std::vector signalNames(); inline int dbcCount() { return allDBCFiles().size(); } int nonEmptyDBCCount(); + std::vector nonEmptyDBCFiles(); const SourceSet sources(const DBCFile *dbc_file) const; DBCFile *findDBCFile(const uint8_t source); diff --git a/openpilot/tools/cabana/deqt.md b/openpilot/tools/cabana/deqt.md deleted file mode 100644 index 3bef2d7ab0..0000000000 --- a/openpilot/tools/cabana/deqt.md +++ /dev/null @@ -1,53 +0,0 @@ -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/openpilot/tools/cabana/mainwin.cc b/openpilot/tools/cabana/mainwin.cc index 2c19df530c..c7a838a756 100644 --- a/openpilot/tools/cabana/mainwin.cc +++ b/openpilot/tools/cabana/mainwin.cc @@ -566,7 +566,7 @@ void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool succe } void MainWindow::updateStatus() { - status_label->setText(tr("Cached Minutes:%1 FPS:%2").arg(settings.max_cached_minutes).arg(settings.fps)); + status_label->setText(tr("Cached Minutes:%1").arg(settings.max_cached_minutes)); } bool MainWindow::eventFilter(QObject *obj, QEvent *event) { diff --git a/openpilot/tools/cabana/messageswidget.cc b/openpilot/tools/cabana/messageswidget.cc index 2325bb3ae4..e2d0eba079 100644 --- a/openpilot/tools/cabana/messageswidget.cc +++ b/openpilot/tools/cabana/messageswidget.cc @@ -345,7 +345,7 @@ bool MessageListModel::filterAndSort() { void MessageListModel::msgsReceived(const std::set *new_msgs, bool has_new_ids) { if (has_new_ids || ((filters_.count(Column::FREQ) || filters_.count(Column::COUNT) || filters_.count(Column::DATA)) && - ++sort_threshold_ == settings.fps)) { + ++sort_threshold_ == STREAM_UPDATE_FPS)) { sort_threshold_ = 0; if (filterAndSort()) return; } diff --git a/openpilot/tools/cabana/settings.cc b/openpilot/tools/cabana/settings.cc index bacbbd5330..17a6611539 100644 --- a/openpilot/tools/cabana/settings.cc +++ b/openpilot/tools/cabana/settings.cc @@ -468,7 +468,6 @@ void writeSetting(json11::Json::object &settings_json, const char *key, const st 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); op(s, "chart_height", settings.chart_height); op(s, "chart_range", settings.chart_range); @@ -480,6 +479,7 @@ void settingsOp(Store &s, SettingOperation op) { op(s, "video_splitter_state", settings.video_splitter_state); op(s, "recent_files", settings.recent_files); op(s, "message_header_state", settings.message_header_state); + op(s, "ui_state", settings.ui_state); op(s, "chart_series_type", settings.chart_series_type); op(s, "theme", settings.theme); op(s, "sparkline_range", settings.sparkline_range); @@ -508,7 +508,8 @@ Settings::Settings() { settingsOp(legacy_settings, [](const auto &s, const char *key, auto &value) { readLegacySetting(s, key, value); }); } } - fps = std::clamp(fps, 1, 100); + // settings written before the "Automatic" theme was dropped hold a 0 for it + if (theme != LIGHT_THEME && theme != DARK_THEME) theme = LIGHT_THEME; } // Must be called before main() returns: json11's internal statistics are constructed on first diff --git a/openpilot/tools/cabana/settings.h b/openpilot/tools/cabana/settings.h index 52353dc667..27138c0890 100644 --- a/openpilot/tools/cabana/settings.h +++ b/openpilot/tools/cabana/settings.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "tools/cabana/core/observable.h" @@ -17,6 +18,9 @@ public: std::vector window_state; std::vector message_header_state; + // UI layout state (dock layout, window geometry, table state), owned by the imgui frontend + std::string ui_state; + Observable<> changed; }; diff --git a/openpilot/tools/cabana/settingsdialog.cc b/openpilot/tools/cabana/settingsdialog.cc index 5dd8b7617c..0fbc82c508 100644 --- a/openpilot/tools/cabana/settingsdialog.cc +++ b/openpilot/tools/cabana/settingsdialog.cc @@ -22,13 +22,8 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) { form_layout->addRow(tr("Color Theme"), theme = new QComboBox(this)); theme->setToolTip(tr("You may need to restart cabana after changes theme")); - theme->addItems({tr("Automatic"), tr("Light"), tr("Dark")}); - theme->setCurrentIndex(settings.theme); - - form_layout->addRow("FPS", fps = new QSpinBox(this)); - fps->setRange(10, 100); - fps->setSingleStep(10); - fps->setValue(settings.fps); + theme->addItems({tr("Light"), tr("Dark")}); + theme->setCurrentIndex(settings.theme - LIGHT_THEME); form_layout->addRow(tr("Max Cached Minutes"), cached_minutes = new QSpinBox(this)); cached_minutes->setRange(MIN_CACHE_MINIUTES, MAX_CACHE_MINIUTES); @@ -79,11 +74,10 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) { } void SettingsDialog::save() { - if (std::exchange(settings.theme, theme->currentIndex()) != settings.theme) { + if (std::exchange(settings.theme, theme->currentIndex() + LIGHT_THEME) != settings.theme) { // set theme before emit changed utils::setTheme(settings.theme); } - settings.fps = fps->value(); settings.max_cached_minutes = cached_minutes->value(); settings.chart_height = chart_height->value(); settings.log_livestream = log_livestream->isChecked(); diff --git a/openpilot/tools/cabana/settingsdialog.h b/openpilot/tools/cabana/settingsdialog.h index c5c723c1cb..895b789f30 100644 --- a/openpilot/tools/cabana/settingsdialog.h +++ b/openpilot/tools/cabana/settingsdialog.h @@ -10,7 +10,6 @@ class SettingsDialog : public QDialog { public: SettingsDialog(QWidget *parent); void save(); - QSpinBox *fps; QSpinBox *cached_minutes; QSpinBox *chart_height; QComboBox *chart_series_type; diff --git a/openpilot/tools/cabana/streams/abstractstream.cc b/openpilot/tools/cabana/streams/abstractstream.cc index 7be579269d..1091bf95c5 100644 --- a/openpilot/tools/cabana/streams/abstractstream.cc +++ b/openpilot/tools/cabana/streams/abstractstream.cc @@ -164,7 +164,7 @@ bool AbstractStream::isMessageActive(const MessageId &id) const { return delta < 1.5; } - return delta < (5.0 / m.freq) + (1.0 / settings.fps); + return delta < (5.0 / m.freq) + (1.0 / STREAM_UPDATE_FPS); } void AbstractStream::updateLastMsgsTo(double sec) { @@ -232,6 +232,10 @@ void AbstractStream::mergeEvents(const std::vector &events) { msg_events[{.source = e->src, .address = e->address}].push_back(e); } + insertEvents(events, msg_events); +} + +void AbstractStream::insertEvents(const std::vector &events, const MessageEventsMap &msg_events) { if (!events.empty()) { for (const auto &[id, new_e] : msg_events) { if (!new_e.empty()) { diff --git a/openpilot/tools/cabana/streams/abstractstream.h b/openpilot/tools/cabana/streams/abstractstream.h index b992b086fd..dedf9a2da6 100644 --- a/openpilot/tools/cabana/streams/abstractstream.h +++ b/openpilot/tools/cabana/streams/abstractstream.h @@ -74,6 +74,7 @@ protected: void cancelWaits(); // call before joining threads, the main thread isn't pumping events during destruction void requestUpdateLastMessages() { postToMainThread([this]() { updateLastMessages(); }); } void mergeEvents(const std::vector &events); + void insertEvents(const std::vector &events, const MessageEventsMap &msg_events); 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(); diff --git a/openpilot/tools/cabana/streams/livestream.cc b/openpilot/tools/cabana/streams/livestream.cc index ec71a6794d..081e63e248 100644 --- a/openpilot/tools/cabana/streams/livestream.cc +++ b/openpilot/tools/cabana/streams/livestream.cc @@ -48,7 +48,6 @@ LiveStream::~LiveStream() { void LiveStream::start() { 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); @@ -62,7 +61,7 @@ void LiveStream::stop() { void LiveStream::updateThread() { while (!exit_) { - std::this_thread::sleep_for(std::chrono::milliseconds(1000 / fps_)); + std::this_thread::sleep_for(std::chrono::milliseconds(1000 / STREAM_UPDATE_FPS)); // coalesce: skip the request if the main thread hasn't processed the previous one yet. if (!update_pending_.exchange(true)) { requestUpdateLastMessages(); @@ -90,7 +89,6 @@ void LiveStream::handleEvent(kj::ArrayPtr data) { // called on the main thread via requestUpdateLastMessages() void LiveStream::updateLastMessages() { update_pending_ = false; - fps_ = settings.fps; { // merge events received from live stream thread. std::lock_guard lk(lock); diff --git a/openpilot/tools/cabana/streams/livestream.h b/openpilot/tools/cabana/streams/livestream.h index 22587335b0..1a17d481f1 100644 --- a/openpilot/tools/cabana/streams/livestream.h +++ b/openpilot/tools/cabana/streams/livestream.h @@ -37,7 +37,6 @@ private: std::mutex lock; std::thread stream_thread, update_thread; std::atomic update_pending_ = false; - std::atomic fps_ = 10; std::vector received_events_; std::chrono::system_clock::time_point begin_date_time; diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index 944cb10fd9..fe8048c215 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -21,6 +21,8 @@ ReplayStream::~ReplayStream() { cancelWaits(); } +// runs on replay's merge thread: a segment of CAN data takes ~30 ms to parse and group, which dropped +// frames when it ran on the main thread. Only the sorted insert and the merged signal need the main thread. void ReplayStream::mergeSegments() { auto event_data = replay->getEventData(); for (const auto &[n, seg] : event_data->segments) { @@ -29,16 +31,19 @@ void ReplayStream::mergeSegments() { std::vector new_events; new_events.reserve(seg->log->events.size()); + MessageEventsMap msg_events; for (const Event &e : seg->log->events) { if (e.which == cereal::Event::Which::CAN) { capnp::FlatArrayMessageReader reader(e.data); auto event = reader.getRoot(); for (const auto &c : event.getCan()) { - new_events.push_back(newEvent(e.mono_time, c)); + const CanEvent *ce = newEvent(e.mono_time, c); + new_events.push_back(ce); + msg_events[{.source = ce->src, .address = ce->address}].push_back(ce); } } } - mergeEvents(new_events); + postToMainThreadAndWait([&]() { insertEvents(new_events, msg_events); }); } } } @@ -56,7 +61,7 @@ bool ReplayStream::loadRoute(const std::string &route, const std::string &data_d waitForSeekFinshed(); }; replay->onQLogLoaded = [this](std::shared_ptr qlog) { postToMainThread([this, qlog]() { qLogLoaded(qlog); }); }; - replay->onSegmentsMerged = [this]() { postToMainThreadAndWait([this]() { mergeSegments(); }); }; + replay->onSegmentsMerged = [this]() { mergeSegments(); }; bool success = replay->load(); if (!success) { @@ -97,7 +102,7 @@ bool ReplayStream::eventFilter(const Event *event) { } double ts = millis_since_boot(); - if ((ts - prev_update_ts) > (1000.0 / settings.fps)) { + if ((ts - prev_update_ts) > (1000.0 / STREAM_UPDATE_FPS)) { requestUpdateLastMessages(); prev_update_ts = ts; } diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 5c5fce9864..09741cd578 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -1,4 +1,5 @@ +#include #include #include #include @@ -8,6 +9,7 @@ #include "tools/cabana/dbc/dbcfile.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/routes.h" +#include "tools/cabana/ui/qtstate.h" #include "tools/cabana/utils/strings.h" const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; @@ -299,6 +301,58 @@ void test_route_json() { REQUIRE(routes::parseRoutes("not json", false).empty()); } +static std::vector fromHex(const std::string &hex) { + std::vector out; + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + out.push_back((uint8_t)std::stoul(hex.substr(i, 2), nullptr, 16)); + } + return out; +} + +void test_qt_state_blobs() { + // blobs written by the Qt frontend + auto geometry = qtstate::parseQtGeometry(fromHex( + "01d9d0cb000300000000000000000014000004ff000003330000000000000014000004ff" + "00000333000000000000000006400000000000000014000004ff00000333")); + REQUIRE(geometry.has_value()); + REQUIRE(geometry->x == 0); + REQUIRE(geometry->y == 20); + REQUIRE(geometry->w == 1280); + REQUIRE(geometry->h == 800); + REQUIRE(geometry->maximized == false); + + auto splitter = qtstate::parseQtSplitter(fromHex("000000ff0000000100000002000000960000006801ffffffff010000000200")); + REQUIRE(splitter.has_value()); + REQUIRE(std::fabs(splitter->ratio - 150.0f / 254.0f) < 1e-6f); + + auto header = qtstate::parseQtHeaderState(fromHex( + "000000ff000000000000000100000000000000000100000000000000000000000000000000000003360000000701" + "01000100000000000000000000000068ffffffff0000008400000000000000070000006800000001000000000000" + "00680000000100000000000000680000000100000000000000680000000100000000000000680000000100000000" + "000000680000000100000000000000c60000000100000002000003e800000000c6")); + REQUIRE(header.has_value()); + REQUIRE(header->sort_section == 0); + REQUIRE(header->sort_order == 0); + REQUIRE(header->sort_shown == true); + const int expected_width[] = {104, 104, 104, 104, 104, 104, 198}; + for (int i = 0; i < qtstate::kMessageColumnCount; ++i) { + REQUIRE(header->visual[i] == i); + REQUIRE(header->width[i] == expected_width[i]); + REQUIRE(header->hidden[i] == false); + } + + // empty, truncated and wrong magic blobs are rejected + REQUIRE(!qtstate::parseQtGeometry({}).has_value()); + REQUIRE(!qtstate::parseQtSplitter({}).has_value()); + REQUIRE(!qtstate::parseQtHeaderState({}).has_value()); + REQUIRE(!qtstate::parseQtGeometry(fromHex("01d9d0cb00030000000000000000")).has_value()); + REQUIRE(!qtstate::parseQtSplitter(fromHex("000000ff000000010000000200000096")).has_value()); + REQUIRE(!qtstate::parseQtHeaderState(fromHex("000000ff0000000000000001000000000000000001")).has_value()); + REQUIRE(!qtstate::parseQtGeometry(fromHex("deadbeef000300000000000000000014000004ff00000333")).has_value()); + REQUIRE(!qtstate::parseQtSplitter(fromHex("000000fe0000000100000002000000960000006801ffffffff010000000200")).has_value()); + REQUIRE(!qtstate::parseQtHeaderState(fromHex("000000fe00000000000000010000000000000000010000000000000000")).has_value()); +} + void test_cabana_core() { test_format_seconds(); test_to_hex(); @@ -313,6 +367,7 @@ void test_cabana_core() { test_route_timestamps(); test_route_api_response(); test_route_json(); + test_qt_state_blobs(); } int main() { diff --git a/openpilot/tools/cabana/tests/test_cabana_ui.py b/openpilot/tools/cabana/tests/test_cabana_ui.py new file mode 100644 index 0000000000..2cb0475041 --- /dev/null +++ b/openpilot/tools/cabana/tests/test_cabana_ui.py @@ -0,0 +1,13 @@ +import subprocess +from pathlib import Path + +from openpilot.common.test import OpenpilotTestCase + +CABANA_DIR = Path(__file__).parent.parent + + +class TestCabanaUi(OpenpilotTestCase): + def test_help(self): + result = subprocess.run(["./_cabana_ui", "-h"], cwd=CABANA_DIR, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert "Usage:" in result.stderr diff --git a/openpilot/tools/cabana/ui/app.cc b/openpilot/tools/cabana/ui/app.cc new file mode 100644 index 0000000000..aab2bfe742 --- /dev/null +++ b/openpilot/tools/cabana/ui/app.cc @@ -0,0 +1,212 @@ +#include "tools/cabana/ui/app.h" + +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_impl_glfw.h" +#include "imgui_impl_opengl3.h" +#include "imgui_impl_opengl3_loader.h" +#include "implot.h" +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/inistate.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/mainwin.h" +#include "tools/cabana/utils/util.h" + +namespace { + +std::atomic g_signal_exit{false}; +std::vector g_key_events; +void keyCallback(GLFWwindow *window, int key, int scancode, int action, int mods) { + ImGui_ImplGlfw_KeyCallback(window, key, scancode, action, mods); + if (action == GLFW_PRESS) g_key_events.push_back({key, mods}); +} +// imgui releases every mouse button when the window loses focus, which aborts a panel tear-off drag and +// docks the panel back. X11 keeps delivering the drag through the implicit grab, so hold a focus loss back +// while a button is down and deliver it after the release (see deliverPendingFocusLoss). +GLFWwindow *g_focus_lost_window = nullptr; +// macOS drops the button on its own when the focus moves, and holding the loss back there swallowed the +// first click in a popup: the click makes the popup's window key, the main window's loss lands on the +// release and imgui clears its mouse state before it sees that release +void windowFocusCallback(GLFWwindow *w, int f) { +#ifdef __APPLE__ + ImGui_ImplGlfw_WindowFocusCallback(w, f); +#else + if (f) { + g_focus_lost_window = nullptr; + ImGui_ImplGlfw_WindowFocusCallback(w, f); + } else { + g_focus_lost_window = w; + } +#endif +} +bool anyMouseButtonDown(GLFWwindow *w) { + for (int b = GLFW_MOUSE_BUTTON_1; b <= GLFW_MOUSE_BUTTON_LAST; ++b) { + if (glfwGetMouseButton(w, b) == GLFW_PRESS) return true; + } + return false; +} +void deliverPendingFocusLoss() { + if (g_focus_lost_window == nullptr || anyMouseButtonDown(g_focus_lost_window)) return; + ImGui_ImplGlfw_WindowFocusCallback(g_focus_lost_window, GLFW_FALSE); + g_focus_lost_window = nullptr; +} + +void hookViewportCallbacks() { + for (ImGuiViewport *viewport : ImGui::GetPlatformIO().Viewports) { + if (viewport->PlatformHandle == nullptr || viewport == ImGui::GetMainViewport()) continue; + glfwSetKeyCallback((GLFWwindow *)viewport->PlatformHandle, keyCallback); + } +} + +void glfwErrorCallback(int error, const char *description) { + fprintf(stderr, "GLFW error %d: %s\n", error, description); +} + +// vsync paces the loop: glfwSwapBuffers blocks until the next refresh. Throttling on top of that beats +// against the refresh rate and makes the camera view stutter. +void renderFrame(GLFWwindow *window, MainWindow *win) { + glfwPollEvents(); + deliverPendingFocusLoss(); + utils::drainMainThreadQueue(); + + int fb_w = 0, fb_h = 0; + glfwGetFramebufferSize(window, &fb_w, &fb_h); + + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplGlfw_NewFrame(); + ImGui::NewFrame(); + win->draw(); + ImGui::Render(); + + const ImVec4 &bg = ImGui::GetStyle().Colors[ImGuiCol_WindowBg]; + glViewport(0, 0, fb_w, fb_h); + glClearColor(bg.x, bg.y, bg.z, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + + if (ImGui::GetIO().ConfigFlags & ImGuiConfigFlags_ViewportsEnable) { + GLFWwindow *backup_context = glfwGetCurrentContext(); + ImGui::UpdatePlatformWindows(); + hookViewportCallbacks(); + ImGui::RenderPlatformWindowsDefault(); + glfwMakeContextCurrent(backup_context); + } + glfwSwapBuffers(window); +} + +class GlfwRuntime { +public: + GlfwRuntime() { + glfwSetErrorCallback(glfwErrorCallback); +#ifdef __APPLE__ + setMacAppName("Cabana"); +#endif + if (!glfwInit()) throw std::runtime_error("glfwInit failed"); + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); +#ifdef __APPLE__ + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); +#endif + window_ = glfwCreateWindow(1600, 900, "Cabana", nullptr, nullptr); + if (window_ == nullptr) { + glfwTerminate(); + throw std::runtime_error("glfwCreateWindow failed"); + } + glfwMakeContextCurrent(window_); + glfwSwapInterval(1); + } + + ~GlfwRuntime() { + if (window_ != nullptr) glfwDestroyWindow(window_); + glfwTerminate(); + } + + GlfwRuntime(const GlfwRuntime &) = delete; + GlfwRuntime &operator=(const GlfwRuntime &) = delete; + GLFWwindow *window() const { return window_; } + +private: + GLFWwindow *window_ = nullptr; +}; + +class ImGuiRuntime { +public: + explicit ImGuiRuntime(GLFWwindow *window) { + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImPlot::CreateContext(); + ImGuiIO &io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; + io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable; + io.ConfigViewportsNoDecoration = false; + io.IniFilename = nullptr; + io.LogFilename = nullptr; + if (!ImGui_ImplGlfw_InitForOpenGL(window, true)) { + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + throw std::runtime_error("ImGui_ImplGlfw_InitForOpenGL failed"); + } + glfwSetKeyCallback(window, keyCallback); + glfwSetWindowFocusCallback(window, windowFocusCallback); + if (!ImGui_ImplOpenGL3_Init("#version 330")) { + ImGui_ImplGlfw_Shutdown(); + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + throw std::runtime_error("ImGui_ImplOpenGL3_Init failed"); + } + } + + ~ImGuiRuntime() { + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplGlfw_Shutdown(); + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + } + + ImGuiRuntime(const ImGuiRuntime &) = delete; + ImGuiRuntime &operator=(const ImGuiRuntime &) = delete; +}; + +} // namespace + +std::vector takeKeyEvents() { + return std::exchange(g_key_events, {}); +} + +int run(std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file) { + try { + // SIGINT/SIGTERM close all windows (which may ask about unsaved changes), then exit + UnixSignalHandler signal_handler([]() { g_signal_exit = true; }); + + GlfwRuntime glfw; + ImGuiRuntime imgui(glfw.window()); + loadFonts(); + applyTheme(settings.theme); + inistate::addSettingsHandler(); + inistate::load(); + inistate::applyWindowGeometry(glfw.window()); + + MainWindow win(glfw.window(), std::move(stream), std::move(stream_loader), dbc_file); + while (!win.exited()) { + if (g_signal_exit.exchange(false)) { + printf("\nexiting...\n"); + win.close(); + } else if (glfwWindowShouldClose(glfw.window())) { + glfwSetWindowShouldClose(glfw.window(), GLFW_FALSE); + win.close(); + } + renderFrame(glfw.window(), &win); + } + return 0; + } catch (const std::exception &e) { + fprintf(stderr, "%s\n", e.what()); + return 1; + } +} diff --git a/openpilot/tools/cabana/ui/app.h b/openpilot/tools/cabana/ui/app.h new file mode 100644 index 0000000000..a981e57e54 --- /dev/null +++ b/openpilot/tools/cabana/ui/app.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/streams/abstractstream.h" + +// builds a stream off the main thread; nullptr means the load failed +using StreamLoader = std::function()>; + +// takes ownership of stream; a loader runs behind the window instead of before it; with neither, the +// stream selector opens +int run(std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file); + +// key presses with the modifier state at event time: imgui may apply a modifier release in the same frame as +// the key press it belongs to, which loses fast shortcut sequences. Consumed once per frame by MainWindow. +struct KeyEvent { + int key; // GLFW_KEY_* + int mods; // GLFW_MOD_* +}; +std::vector takeKeyEvents(); diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc new file mode 100644 index 0000000000..4dedc9074c --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -0,0 +1,790 @@ +#define IMGUI_DEFINE_MATH_OPERATORS // ImVec2 arithmetic, must precede imgui.h +#include "tools/cabana/ui/chart/chart.h" + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/core/settings.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +const int AXIS_X_TOP_MARGIN = 4; +const int X_TICK_COUNT = 5; +const double MIN_ZOOM_SECONDS = 0.01; // 10ms +const double EPSILON = 1e-6; +constexpr ImVec4 LAYOUT_MARGINS{8, 6, 8, 6}; // left, top, right, bottom +static inline bool xLessThan(const ImPlotPoint &p, double x) { return p.x < (x - EPSILON); } +static inline bool isNull(const ImPlotPoint &p) { return p.x == 0 && p.y == 0; } + +static std::string formatNumber(double value, int precision) { + char buf[64]; + snprintf(buf, sizeof(buf), "%.*f", precision, value); + return buf; +} + +// the decimals needed to tell tick_count ticks over range apart +static int axisPrecision(double range, int tick_count, int min_precision) { + return std::max(int(-std::floor(std::log10(range / (tick_count - 1)))), min_precision); +} + +static void addTextEllipsis(ImDrawList *dl, ImFont *font, ImU32 col, const ImVec2 &pos, float max_x, const std::string &text) { + const float size = ImGui::GetFontSize(); + ImGui::PushFont(font, 0.0f); + ImGui::RenderTextEllipsis(dl, pos, ImVec2(max_x, pos.y + size), max_x, text.c_str(), nullptr, nullptr); + ImGui::PopFont(); +} + +ChartView::ChartView(const std::pair &x_range, ChartsWidget *parent) + : x_min_(x_range.first), x_max_(x_range.second), charts_widget_(parent) { + series_type_ = (SeriesType)settings.chart_series_type; + + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { signalRemoved(sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { signalUpdated(sig); })); + connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { msgRemoved(id); })); +} + +void ChartView::drawMenuActions() { + // the current series type is marked with a radio bullet on the left + const float indent = ImGui::GetFontSize(); + float label_width = ImGui::CalcTextSize("Manage Signals").x; + for (const char *type : SERIES_TYPE_NAMES) label_width = std::max(label_width, ImGui::CalcTextSize(type).x); + for (int i = 0; i < (int)std::size(SERIES_TYPE_NAMES); ++i) { + if (radioMenuItem(SERIES_TYPE_NAMES[i], i == (int)series_type_, indent + label_width + indent)) { + setSeriesType((SeriesType)i); + } + } + ImGui::Separator(); + ImGui::Indent(indent); + if (ImGui::MenuItem("Manage Signals")) manageSignals(); + if (ImGui::MenuItem("Split Chart", nullptr, false, sigs_.size() > 1)) charts_widget_->splitChart(this); + ImGui::Unindent(indent); +} + +// the buttons and their menus are drawn every frame, at the rects updateLayout() placed them at +void ChartView::createToolButtons() { + ImGui::SetCursorScreenPos(layout_.close_btn_rect.Min); + bool close_clicked = toolButton("close_btn", icon::X, "Remove Chart"); + + ImGui::SetCursorScreenPos(layout_.manage_btn_rect.Min); + if (toolButton("manage_btn", icon::LIST, "")) ImGui::OpenPopup("manage_menu"); + if (ImGui::BeginPopup("manage_menu")) { + drawMenuActions(); + ImGui::EndPopup(); + } + + if (close_clicked) charts_widget_->removeChart(this); +} + +void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) { + if (hasSignal(msg_id, sig)) return; + + sigs_.push_back({.msg_id = msg_id, .sig = sig, .color = uniqueColor(sig->color)}); + updateSeries(sig); + charts_widget_->seriesChanged(); +} + +bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const { + return std::any_of(sigs_.cbegin(), sigs_.cend(), [&](auto &s) { return s.msg_id == msg_id && s.sig == sig; }); +} + +void ChartView::removeIf(std::function predicate) { + int prev_size = sigs_.size(); + 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) { + charts_widget_->seriesChanged(); + updateAxisY(); + } +} + +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->color == sig->color)) { + it->color = uniqueColor(sig->color, sig); + } + updateSeries(sig); + } +} + +void ChartView::manageSignals() { + auto dlg = std::make_unique("Manage Chart"); + for (auto &s : sigs_) { + dlg->addSelected(s.msg_id, s.sig); + } + // runs once the dialog is accepted, dropped if the chart is removed first + charts_widget_->execSignalSelector(std::move(dlg), this, [this](SignalSelector &selector) { + const auto &items = selector.selectedItems(); + for (const auto &s : items) { + addSignal(s.msg_id, s.sig); + } + removeIf([&](auto &s) { + return std::none_of(items.cbegin(), items.cend(), [&](auto &it) { return s.msg_id == it.msg_id && s.sig == it.sig; }); + }); + }); +} + +void ChartView::updateLayout() { + const ImVec2 grip = ImGui::CalcTextSize(icon::GRIP_HORIZONTAL); + const ImVec2 top_left = layout_.rect.Min + ImVec2(LAYOUT_MARGINS.x, LAYOUT_MARGINS.y); + layout_.move_icon_rect = ImRect(top_left, top_left + grip); + const ImVec2 pad = ImGui::GetStyle().FramePadding * 2; + const ImVec2 close_size = ImGui::CalcTextSize(icon::X) + pad; + const ImVec2 manage_size = ImGui::CalcTextSize(icon::LIST) + pad; + const ImVec2 close_min(layout_.rect.Max.x - LAYOUT_MARGINS.z - close_size.x, top_left.y); + layout_.close_btn_rect = ImRect(close_min, close_min + close_size); + const ImVec2 manage_min(close_min.x - manage_size.x - ImGui::GetStyle().ItemSpacing.x, top_left.y); + layout_.manage_btn_rect = ImRect(manage_min, manage_min + manage_size); + + ImFont *bold = boldFont(); + const float font_size = ImGui::GetFontSize(); + const float fm_height = ImGui::GetTextLineHeight(); + const int marker_size = markerSize(); + const int row_height = std::max(marker_size, fm_height) + fm_height + 3; // + the signal value line + const int legend_left = layout_.move_icon_rect.Max.x + LAYOUT_MARGINS.x; + const int legend_right = std::max(layout_.manage_btn_rect.Min.x - LAYOUT_MARGINS.z, legend_left + 10); + + // layout legend entries left-to-right, wrapping between the move icon and the buttons + layout_.legend_rects.clear(); + int x = legend_left, y = top_left.y; + for (auto &s : sigs_) { + int w = marker_size + 5 + bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x + + ImGui::CalcTextSize(msgLabel(s.msg_id).c_str()).x; + 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; + } + layout_.legend_rects.emplace_back(ImVec2(x, y), ImVec2(x + w, y + std::max(marker_size, fm_height))); + x += w + 12; + } + + // add top space for the legend and signal values + int adjust_top = (y + row_height) - top_left.y; + adjust_top = std::max(adjust_top, layout_.manage_btn_rect.Max.y - layout_.rect.Min.y + LAYOUT_MARGINS.y); + layout_.header_bottom = layout_.rect.Min.y + adjust_top + LAYOUT_MARGINS.y; +} + +void ChartView::updatePlot(double cur, double min, double max) { + cur_sec_ = cur; + if (min != x_min_ || max != x_max_) { + x_min_ = min; + x_max_ = max; + updateAxisY(); + if (tooltip_x_ >= 0) { + showTip(secondsAtPoint({(float)tooltip_x_, 0})); + } + } +} + +void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector &events, + std::vector &vals, std::vector &step_vals) { + vals.reserve(vals.size() + events.size()); + step_vals.reserve(step_vals.size() + events.size() * 2); + + double value = 0; + for (const CanEvent *e : events) { + if (sig->getValue(e->dat, e->size, &value)) { + const double ts = can->toSeconds(e->mono_time); + vals.emplace_back(ts, value); + if (!step_vals.empty()) + step_vals.emplace_back(ts, step_vals.back().y); + step_vals.emplace_back(ts, value); + } + } +} + +void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap *msg_new_events) { + for (auto &s : sigs_) { + if (!sig || s.sig == sig) { + if (!msg_new_events) { + s.vals.clear(); + s.step_vals.clear(); + } + auto events = msg_new_events ? msg_new_events : &can->eventsMap(); + auto it = events->find(s.msg_id); + if (it == events->end() || it->second.empty()) continue; + + if (s.vals.empty() || can->toSeconds(it->second.back()->mono_time) > s.vals.back().x) { + appendCanEvents(s.sig, it->second, s.vals, s.step_vals); + } else { + std::vector vals, step_vals; + appendCanEvents(s.sig, it->second, vals, step_vals); + if (vals.empty()) continue; + s.vals.insert(std::lower_bound(s.vals.begin(), s.vals.end(), vals.front().x, xLessThan), + vals.begin(), vals.end()); + s.step_vals.insert(std::lower_bound(s.step_vals.begin(), s.step_vals.end(), step_vals.front().x, xLessThan), + step_vals.begin(), step_vals.end()); + } + + if (!can->liveStreaming()) { + s.segment_tree.build(s.vals.size(), [&vals = s.vals](int i) { return vals[i].y; }); + } + } + } + updateAxisY(); +} + +std::pair ChartView::visibleRange(const std::vector &points) const { + auto first = std::lower_bound(points.cbegin(), points.cend(), x_min_, xLessThan); + auto last = std::lower_bound(first, points.cend(), x_max_, xLessThan); + return {first, last}; +} + +const ImPlotPoint *ChartView::lastPointBefore(const SigItem &s, double sec) const { + auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double x) { return p.x > x + EPSILON; }); + return it != s.vals.crend() && it->x >= x_min_ ? &*it : nullptr; +} + +void ChartView::updateAxisY() { + if (sigs_.empty()) return; + + double min = std::numeric_limits::max(); + double max = std::numeric_limits::lowest(); + std::string unit = sigs_[0].sig->unit; + + for (auto &s : sigs_) { + if (!s.visible) continue; + + // Only show unit when all signals have the same unit + if (unit != s.sig->unit) { + unit.clear(); + } + + auto [first, last] = visibleRange(s.vals); + s.min = std::numeric_limits::max(); + s.max = std::numeric_limits::lowest(); + if (can->liveStreaming()) { + for (auto it = first; it != last; ++it) { + if (it->y < s.min) s.min = it->y; + if (it->y > s.max) s.max = it->y; + } + } else { + std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last)); + } + min = std::min(min, s.min); + max = std::max(max, s.max); + } + if (min == std::numeric_limits::max()) min = 0; + if (max == std::numeric_limits::lowest()) max = 0; + + y_unit_ = unit; + + 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 != y_min_ || max_y != y_max_) { + y_min_ = min_y; + y_max_ = max_y; + y_tick_count_ = tick_count; + y_precision_ = axisPrecision(max_y - min_y, tick_count, 0); + } +} + +std::tuple ChartView::getNiceAxisNumbers(double min, double max, int tick_count) { + double range = niceNumber((max - min), true); // range with ceiling + double step = niceNumber(range / (tick_count - 1), false); + min = std::floor(min / step); + max = std::ceil(max / step); + tick_count = int(max - min) + 1; + return {min * step, max * step, tick_count}; +} + +int ChartView::xAxisPrecision() const { + return axisPrecision(x_max_ - x_min_, X_TICK_COUNT, 2); +} + +// nice numbers can be expressed as form of 1*10^n, 2* 10^n or 5*10^n +double ChartView::niceNumber(double x, bool ceiling) { + double z = std::pow(10, std::floor(std::log10(x))); // the largest 10^n smaller than x + double q = x / z; // 1 <= q < 10 + if (ceiling) { + if (q <= 1.0) q = 1; + else if (q <= 2.0) q = 2; + else if (q <= 5.0) q = 5; + else q = 10; + } else { + if (q < 1.5) q = 1; + else if (q < 3.0) q = 2; + else if (q < 7.0) q = 5; + else q = 10; + } + return q * z; +} + +void ChartView::drawContextMenu() { + if (drawing_ghost_) return; + // the menu opens on right press; a right release with no menu open reaches handleMouseRelease + if (ImGui::IsMouseClicked(ImGuiMouseButton_Right) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows) && + !ImGui::IsAnyItemActive()) { + ImGui::OpenPopup("context_menu"); + } + context_menu_id_ = ImGui::GetID("context_menu"); + if (ImGui::BeginPopup("context_menu")) { + drawMenuActions(); + // the menu holds checkable entries, so every entry keeps the same left margin + const float indent = ImGui::GetFontSize(); + ImGui::Indent(indent); + ImGui::Separator(); + // the zoom entries come from the toolbar, where they are only visible while zoomed + if (can->timeRange().has_value()) { + const std::string undo_text = std::string(icon::ARROW_COUNTERCLOCKWISE) + " Undo Zoom"; + const std::string redo_text = std::string(icon::ARROW_CLOCKWISE) + " Redo Zoom"; + if (ImGui::MenuItem(undo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canUndo())) charts_widget_->zoom_undo_stack_.undo(); + if (ImGui::MenuItem(redo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canRedo())) charts_widget_->zoom_undo_stack_.redo(); + ImGui::Separator(); + } + if (ImGui::MenuItem("Close")) charts_widget_->removeChart(this); + ImGui::Unindent(indent); + ImGui::EndPopup(); + } +} + +void ChartView::handleMousePress() { + if (drawing_ghost_) return; + const ImVec2 pos = ImGui::GetMousePos(); + // a press on the close/manage buttons does not reach the widget + const bool widget_pressed = ImGui::IsMouseClicked(ImGuiMouseButton_Left) && layout_.rect.Contains(pos) && + ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && + !layout_.close_btn_rect.Contains(pos) && !layout_.manage_btn_rect.Contains(pos); + if (!widget_pressed) return; + press_pos_ = pos; + if (layout_.move_icon_rect.Contains(pos)) return; // the move icon press is handled by the grip item (startChartDrag) + + if (ImGui::GetIO().KeyShift) { + // Save current playback state when scrubbing + resume_after_scrub_ = !can->isPaused(); + if (resume_after_scrub_) { + can->pause(true); + } + mouse_mode_ = MouseMode::Scrub; + } else if (layout_.plot_area.Contains(pos)) { + mouse_mode_ = MouseMode::Rubber; + rubber_rect_ = ImRect(); + } +} + +void ChartView::handleMouseMove() { + if (drawing_ghost_) return; + const ImVec2 pos = ImGui::GetMousePos(); + const ImVec2 delta = ImGui::GetIO().MouseDelta; + // a click alone must not hide the tip + if (delta.x == 0 && delta.y == 0) return; + // only the widget under the mouse, or the one dragging, reacts to a move + if (mouse_mode_ == MouseMode::None && !layout_.rect.Contains(pos)) return; + + if (mouse_mode_ == MouseMode::Scrub && ImGui::GetIO().KeyShift) { + if (layout_.plot_area.Contains(pos)) { + can->seekTo(std::clamp(secondsAtPoint(pos), can->minSeconds(), can->maxSeconds())); + } + } + + if (mouse_mode_ == MouseMode::Rubber) { + // horizontal selection, clamped to the plot area + float left = std::clamp(std::min(press_pos_.x, pos.x), layout_.plot_area.Min.x, layout_.plot_area.Max.x); + float right = std::clamp(std::max(press_pos_.x, pos.x), layout_.plot_area.Min.x, layout_.plot_area.Max.x); + rubber_rect_ = ImRect(ImVec2(left, layout_.plot_area.Min.y), ImVec2(right, layout_.plot_area.Max.y)); + } + + clearTrackPoints(); + if (mouse_mode_ != MouseMode::Rubber && layout_.plot_area.Contains(pos) && (layout_.plot_hovered || mouse_mode_ != MouseMode::None) && + ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow)) { + charts_widget_->showValueTip(secondsAtPoint(pos)); + } else if (tip_label_.isVisible()) { + charts_widget_->showValueTip(-1); + } +} + +void ChartView::handleMouseRelease() { + if (drawing_ghost_) return; + const bool left_released = ImGui::IsMouseReleased(ImGuiMouseButton_Left); + const bool right_released = ImGui::IsMouseReleased(ImGuiMouseButton_Right) && layout_.rect.Contains(ImGui::GetMousePos()); + if (!left_released && !right_released) return; + if (left_released && mouse_mode_ == MouseMode::Rubber) { + mouse_mode_ = MouseMode::None; + // Prevent zooming/seeking past the end of the route + double min = std::clamp(secondsAtPoint(rubber_rect_.Min), can->minSeconds(), can->maxSeconds()); + double max = std::clamp(secondsAtPoint(rubber_rect_.Max), can->minSeconds(), can->maxSeconds()); + if (rubber_rect_.GetWidth() <= 0) { + // no rubber dragged, seek to mouse position + can->seekTo(std::clamp(secondsAtPoint(press_pos_), can->minSeconds(), can->maxSeconds())); + } else if (rubber_rect_.GetWidth() > 10 && (max - min) > MIN_ZOOM_SECONDS) { + charts_widget_->zoom_undo_stack_.push(new ZoomCommand({min, max})); + } + rubber_rect_ = ImRect(); + } else if (right_released && !ImGui::IsPopupOpen(context_menu_id_, ImGuiPopupFlags_None)) { + charts_widget_->zoom_undo_stack_.undo(); + } + + if (mouse_mode_ == MouseMode::Scrub) { + mouse_mode_ = MouseMode::None; + if (resume_after_scrub_) { + can->pause(false); + resume_after_scrub_ = false; + } + } +} + +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(); + charts_widget_->removeChart(source); +} + +std::vector ChartView::takeExtraSignals() { + std::vector extra; + for (auto it = sigs_.begin() + 1; it != sigs_.end(); ++it) { + it->color = it->sig->color; + extra.push_back(std::move(*it)); + } + sigs_.resize(1); + updateAxisY(); + return extra; +} + +void ChartView::adoptSignal(SigItem s) { + sigs_.push_back(std::move(s)); + updateAxisY(); +} + +void ChartView::showTip(double sec) { + ImRect tip_area(ImVec2(layout_.rect.Min.x, layout_.plot_area.Min.y), ImVec2(layout_.rect.Max.x, layout_.plot_area.Max.y)); + ImRect visible_rect = charts_widget_->chartVisibleRect(this); + visible_rect.ClipWith(tip_area); + if (visible_rect.GetWidth() <= 0 || visible_rect.GetHeight() <= 0) { + tip_label_.hide(); + return; + } + + tooltip_x_ = xPos(sec); + float x = -1; + std::vector text_list; + for (auto &s : sigs_) { + if (s.visible) { + std::string value = "--"; + if (const ImPlotPoint *pt = lastPointBefore(s, sec)) { + value = s.sig->formatValue(pt->y, false); + s.track_pt = *pt; + x = std::max(x, xPos(pt->x)); + } + std::string name = sigs_.size() > 1 ? s.sig->name + ": " : ""; + std::string min = s.min == std::numeric_limits::max() ? "--" : utils::toString(s.min); + std::string max = s.max == std::numeric_limits::lowest() ? "--" : utils::toString(s.max); + text_list.push_back({.has_marker = true, .marker = toImU32(s.color), .name = name, .bold = value, .rest = " (" + min + ", " + max + ")"}); + } + } + if (x < 0) { + x = tooltip_x_; + } + ImVec2 pt(x, layout_.plot_area.Min.y); + text_list.insert(text_list.begin(), TipLine{.name = formatNumber(secondsAtPoint({x, 0}), 3)}); + tip_label_.showText(pt, text_list, visible_rect); +} + +void ChartView::hideTip() { + clearTrackPoints(); + tooltip_x_ = -1; + tip_label_.hide(); +} + +void ChartView::draw(float width) { + ImGui::PushID(this); + width = std::max(width, (float)CHART_MIN_WIDTH); + layout_.plot_hovered = false; + // the tile geometry is known before the child is entered, so it stays valid when imgui culls a scrolled out chart + const ImVec2 tile_pos = ImGui::GetCursorScreenPos(); + const ImVec2 tile_size(width, (float)settings.chart_height); + layout_.rect = ImRect(tile_pos, tile_pos + tile_size); + if (ImGui::BeginChild("chart", tile_size, ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + updateLayout(); + paint(); + drawContextMenu(); + } + ImGui::EndChild(); + // a chart scrolled out of the viewport draws no tip + const ImRect visible_rect = charts_widget_->chartVisibleRect(this); + if (!drawing_ghost_ && visible_rect.GetWidth() > 0 && visible_rect.GetHeight() > 0) tip_label_.draw(); + ImGui::PopID(); +} + +void ChartView::drawGhost(float width) { + // the ghost is drawn in its own window: keep the geometry of the live tile so hit testing stays correct + drawing_ghost_ = true; + const Layout saved = layout_; + draw(width); + layout_ = saved; + drawing_ghost_ = false; +} + +void ChartView::paint() { + drawStaticLayer(); + + if (can_drop_) { + ImGui::GetWindowDrawList()->AddRect(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_Header), 0.0f, 0, 4.0f); + } +} + +void ChartView::drawStaticLayer() { + ImDrawList *painter = ImGui::GetWindowDrawList(); + painter->AddRectFilled(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_ChildBg)); + ImGui::SetCursorScreenPos(layout_.move_icon_rect.Min); + ImGui::InvisibleButton("grip", layout_.move_icon_rect.GetSize()); + if (ImGui::IsItemActivated()) charts_widget_->startChartDrag(this, ImGui::GetMousePos()); + if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + painter->AddText(layout_.move_icon_rect.Min, ImGui::GetColorU32(ImGuiCol_Text), icon::GRIP_HORIZONTAL); + createToolButtons(); + drawLegend(); + drawSignalValue(); // drawn here because implot clips the plot frame + drawAxes(); +} + +void ChartView::drawAxes() { + ImGui::SetCursorScreenPos(ImVec2(layout_.rect.Min.x, layout_.header_bottom)); + const float plot_h = std::max(layout_.rect.Max.y - layout_.header_bottom - LAYOUT_MARGINS.w, 10.0f); + ImPlot::PushStyleVar(ImPlotStyleVar_PlotPadding, ImVec2(LAYOUT_MARGINS.x, AXIS_X_TOP_MARGIN)); + ImPlot::PushStyleColor(ImPlotCol_PlotBg, ImVec4(0, 0, 0, 0)); + ImPlot::PushStyleColor(ImPlotCol_FrameBg, ImVec4(0, 0, 0, 0)); + // every tick is a 1 px line in the text color at alpha 50, the edge ticks close the box, no tick marks. + // that alpha washes out on the dark base, so the dark theme draws opaque guides in a mid gray instead. + const bool dark = isDarkTheme(); + ImVec4 grid_color; + if (dark) { + grid_color = colorRgb(DarkTheme::light.r, DarkTheme::light.g, DarkTheme::light.b); + } else { + grid_color = ImGui::GetStyleColorVec4(ImGuiCol_Text); + grid_color.w = 50.0f / 255.0f; + } + ImPlot::PushStyleColor(ImPlotCol_AxisGrid, grid_color); + ImPlot::PushStyleColor(ImPlotCol_PlotBorder, grid_color); + ImPlot::PushStyleColor(ImPlotCol_AxisTick, ImVec4(0, 0, 0, 0)); + ImPlot::PushStyleColor(ImPlotCol_AxisText, ImGui::GetStyleColorVec4(ImGuiCol_Text)); + ImPlot::PushStyleVar(ImPlotStyleVar_MajorTickLen, ImVec2(0, 0)); + // MajorGridSize is the per-axis line thickness; thicker guides read better on the dark base + ImPlot::PushStyleVar(ImPlotStyleVar_MajorGridSize, dark ? ImVec2(2.0f, 2.0f) : ImVec2(1.0f, 1.0f)); + const ImPlotFlags flags = ImPlotFlags_NoTitle | ImPlotFlags_NoLegend | ImPlotFlags_NoMenus | ImPlotFlags_NoMouseText | + ImPlotFlags_NoBoxSelect | ImPlotFlags_NoInputs | ImPlotFlags_NoFrame; + const ImPlotAxisFlags axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight | ImPlotAxisFlags_NoSideSwitch | ImPlotAxisFlags_Lock; + // reserve room for the right half of the last x tick label + const float x_label_width = ImGui::CalcTextSize(formatNumber(x_max_, xAxisPrecision()).c_str()).x + 5; + if (ImPlot::BeginPlot("##plot", ImVec2(layout_.rect.GetWidth() - x_label_width / 2, plot_h), flags)) { + ImPlot::SetupAxis(ImAxis_X1, nullptr, axis_flags); + ImPlot::SetupAxis(ImAxis_Y1, y_unit_.empty() ? nullptr : y_unit_.c_str(), axis_flags); + ImPlot::SetupAxisLimits(ImAxis_X1, x_min_, x_max_, ImPlotCond_Always); + ImPlot::SetupAxisLimits(ImAxis_Y1, y_min_, y_max_, ImPlotCond_Always); + // the format must be set before the ticks are generated + ImPlot::SetupAxisFormat(ImAxis_Y1, ("%." + std::to_string(y_precision_) + "f").c_str()); + ImPlot::SetupAxisTicks(ImAxis_Y1, y_min_, y_max_, y_tick_count_); + ImPlot::SetupAxisFormat(ImAxis_X1, ("%." + std::to_string(xAxisPrecision()) + "f").c_str()); + ImPlot::SetupAxisTicks(ImAxis_X1, x_min_, x_max_, X_TICK_COUNT); + ImPlot::SetupFinish(); + + layout_.plot_area = ImRect(ImPlot::GetPlotPos(), ImPlot::GetPlotPos() + ImPlot::GetPlotSize()); + // ImPlotFlags_NoInputs disables implot's own hover tracking + layout_.plot_hovered = layout_.plot_area.Contains(ImGui::GetMousePos()) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + drawSeries(); + handleMousePress(); + handleMouseMove(); + handleMouseRelease(); + drawForeground(); + ImPlot::EndPlot(); + } + ImPlot::PopStyleColor(6); + ImPlot::PopStyleVar(3); +} + +void ChartView::drawLegend() { + ImDrawList *painter = ImGui::GetWindowDrawList(); + const ImU32 title_color = ImGui::GetColorU32(ImGuiCol_Text); + // Draw message details in similar color, but slightly fade it to the background + const ImU32 msg_color = withAlpha(title_color, 180); + ImFont *bold = boldFont(); + ImFont *normal = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + const float marker_size = markerSize(); + + for (int i = 0; i < sigs_.size() && i < layout_.legend_rects.size(); ++i) { + const auto &s = sigs_[i]; + const ImRect &r = layout_.legend_rects[i]; + // toggle series visibility by clicking its legend entry + ImGui::PushID(i); + ImGui::SetCursorScreenPos(r.Min); + if (ImGui::InvisibleButton("legend", ImVec2(std::max(r.GetWidth(), 1.0f), std::max(r.GetHeight(), 1.0f))) && + mouse_mode_ == MouseMode::None && sigs_.size() > 1) { + sigs_[i].visible = !sigs_[i].visible; + updateAxisY(); + } + ImGui::PopID(); + + if (series_type_ == SeriesType::Scatter) { + painter->AddCircleFilled(r.Min + ImVec2(marker_size / 2.0f, 2.0f + marker_size / 2.0f), marker_size / 2.0f, toImU32(s.color)); + } else { + drawColorMarker(painter, r.Min, toImU32(s.color)); + } + + float x = r.Min.x + marker_size + 5; + const float text_y = r.GetCenter().y - font_size / 2.0f; + addTextEllipsis(painter, bold, title_color, ImVec2(x, text_y), r.Max.x, s.sig->name); + float name_w = std::min(bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x, r.Max.x - x); + x += name_w; + const std::string msg = msgLabel(s.msg_id); + addTextEllipsis(painter, normal, msg_color, ImVec2(x, text_y), r.Max.x, msg); + if (!s.visible) { // strike out + const float y = r.GetCenter().y; + painter->AddLine(ImVec2(r.Min.x + marker_size + 5, y), ImVec2(std::min(x + ImGui::CalcTextSize(msg.c_str()).x, r.Max.x), y), title_color); + } + } +} + +void ChartView::drawSeries() { + for (int i = 0; i < sigs_.size(); ++i) { + auto &s = sigs_[i]; + if (!s.visible) continue; + + // visible points in vals to compute point density + auto [first, last] = visibleRange(s.vals); + int num_points = std::max(last - first, 1); + double pixels_per_point = 0; + if (first != last) { + const ImPlotPoint &right_pt = last == s.vals.cend() ? s.vals.back() : *last; + pixels_per_point = (xPos(right_pt.x) - xPos(first->x)) / num_points; + } + + const std::string label = "##sig" + std::to_string(i); + ImPlotSpec spec; + spec.LineColor = toImVec4(s.color); + spec.Stride = sizeof(ImPlotPoint); + if (series_type_ == SeriesType::Scatter) { + float radius = std::clamp(pixels_per_point / 2.0, 2.0, 8.0) / 2.0; + spec.Marker = ImPlotMarker_Circle; + spec.MarkerSize = radius; + if (first != last) ImPlot::PlotScatter(label.c_str(), &first->x, &first->y, last - first, spec); + } else { + const auto &points = series_type_ == SeriesType::StepLine ? s.step_vals : s.vals; + // one sample beyond each edge so the line runs out of the plot + auto [begin, end] = visibleRange(points); + if (begin != points.cbegin()) --begin; + if (end != points.cend()) ++end; + if (begin == end) continue; + + spec.LineWeight = 2; + ImPlot::PlotLine(label.c_str(), &begin->x, &begin->y, end - begin, spec); + + // show points when zoomed in enough + if ((num_points == 1 || pixels_per_point > 20) && first != last) { + ImPlotSpec dots; + dots.LineColor = toImVec4(s.color); + dots.Stride = sizeof(ImPlotPoint); + dots.Marker = ImPlotMarker_Circle; + dots.MarkerSize = 4; + ImPlot::PlotScatter((label + "_pts").c_str(), &first->x, &first->y, last - first, dots); + } + } + } +} + +void ChartView::drawForeground() { + drawTimeline(); + ImDrawList *painter = ImPlot::GetPlotDrawList(); + ImPlot::PushPlotClipRect(); + float track_line_x = -1; + for (auto &s : sigs_) { + if (!isNull(s.track_pt) && s.visible) { + ImVec2 pos(xPos(s.track_pt.x), yPos(s.track_pt.y)); + painter->AddCircleFilled(pos, 5.5f, toImU32(s.color.darker(125))); + track_line_x = std::max(track_line_x, pos.x); + } + } + if (track_line_x > 0) { + const ImU32 dark_gray = IM_COL32(0x80, 0x80, 0x80, 0xff); + for (float y = layout_.plot_area.Min.y; y < layout_.plot_area.Max.y; y += 8) { + painter->AddLine(ImVec2(track_line_x, y), ImVec2(track_line_x, std::min(y + 4, layout_.plot_area.Max.y)), dark_gray, 1.0f); + } + } + ImPlot::PopPlotClipRect(); + + drawRubberBandTimeRange(); +} + +void ChartView::drawRubberBandTimeRange() { + if (rubber_rect_.GetWidth() <= 1) return; + + ImDrawList *painter = ImPlot::GetPlotDrawList(); + // ImGuiCol_Header is translucent, so the 1px selection outline is drawn at full alpha + const ImU32 highlight = withAlpha(ImGui::GetColorU32(ImGuiCol_Header), 255); + painter->AddRectFilled(rubber_rect_.Min, rubber_rect_.Max, withAlpha(highlight, 50)); + painter->AddRect(rubber_rect_.Min, rubber_rect_.Max, highlight); + + // time labels at the bottom corners (below the plot, so clip to the widget instead of the plot) + const ImU32 white = IM_COL32_WHITE; + const ImU32 gray = IM_COL32(0xa0, 0xa0, 0xa4, 0xff); + painter = ImGui::GetWindowDrawList(); + painter->PushClipRect(layout_.rect.Min, layout_.rect.Max); + for (const auto &pt : {rubber_rect_.GetBL(), rubber_rect_.GetBR()}) { + std::string sec = formatNumber(secondsAtPoint(pt), 2); + ImVec2 size = ImGui::CalcTextSize(sec.c_str()) + ImVec2(12, AXIS_X_TOP_MARGIN * 2); + ImVec2 top_left = pt.x == rubber_rect_.Min.x ? ImVec2(pt.x - size.x, pt.y + 2) : ImVec2(pt.x, pt.y + 2); + painter->AddRectFilled(top_left, top_left + size, gray); + painter->AddText(top_left + ImVec2(6, AXIS_X_TOP_MARGIN), white, sec.c_str()); + } + painter->PopClipRect(); +} + +void ChartView::drawTimeline() { + ImDrawList *painter = ImPlot::GetPlotDrawList(); + float x = std::clamp(xPos(cur_sec_), layout_.plot_area.Min.x, layout_.plot_area.Max.x); + painter->AddLine(ImVec2(x, layout_.plot_area.Min.y - 1.0f), ImVec2(x, layout_.plot_area.Max.y + 1.0f), ImGui::GetColorU32(ImGuiCol_Text), 1.0f); + + std::string time_str = formatNumber(cur_sec_, 2); + ImVec2 time_str_size = ImGui::CalcTextSize(time_str.c_str()) + ImVec2(8, 2); + ImVec2 time_str_pos(x - time_str_size.x / 2.0f, layout_.plot_area.Max.y + AXIS_X_TOP_MARGIN); + const bool dark = isDarkTheme(); + painter->AddRectFilled(time_str_pos, time_str_pos + time_str_size, dark ? IM_COL32(0x80, 0x80, 0x80, 0xff) : IM_COL32(0xa0, 0xa0, 0xa4, 0xff), 3.0f); + painter->AddText(time_str_pos + ImVec2(4, 1), IM_COL32_WHITE, time_str.c_str()); +} + +void ChartView::drawSignalValue() { + ImDrawList *painter = ImGui::GetWindowDrawList(); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); + for (int i = 0; i < sigs_.size() && i < layout_.legend_rects.size(); ++i) { + const auto &s = sigs_[i]; + const ImPlotPoint *pt = lastPointBefore(s, cur_sec_); + std::string value = pt ? s.sig->formatValue(pt->y) : "--"; + const ImVec2 value_min = layout_.legend_rects[i].GetBL() - ImVec2(0, 1); + ImRect value_rect(value_min, value_min + layout_.legend_rects[i].GetSize()); + float w = ImGui::CalcTextSize(value.c_str()).x; + if (w <= value_rect.GetWidth()) { + painter->AddText(ImVec2(value_rect.GetCenter().x - w / 2, value_rect.Min.y), color, value.c_str()); + } else { + addTextEllipsis(painter, ImGui::GetFont(), color, value_rect.Min, value_rect.Max.x, value); + } + } +} + +CabanaColor ChartView::uniqueColor(CabanaColor color, const cabana::Signal *exclude) const { + for (auto &s : sigs_) { + if (s.sig != exclude && std::abs(color.hsv().hue - s.color.hsv().hue) < 0.1) { + // use different color to distinguish it from others. + 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 = CabanaColor::fromHsv(std::fmod(last_color.hsv().hue + 60 / 360.0, 1.0), + sat(rng) / 100.0, + val(rng) / 100.0, + color.a / 255.0f); + break; + } + } + return color; +} diff --git a/openpilot/tools/cabana/ui/chart/chart.h b/openpilot/tools/cabana/ui/chart/chart.h new file mode 100644 index 0000000000..fc2b63712c --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chart.h @@ -0,0 +1,142 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "implot.h" + +#include "tools/cabana/ui/chart/tiplabel.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/util.h" + +enum class SeriesType { + Line = 0, + StepLine, + Scatter +}; +inline constexpr const char *SERIES_TYPE_NAMES[] = {"Line", "Step Line", "Scatter"}; + +// the message part of a legend entry, drawn after the signal name +inline std::string msgLabel(const MessageId &id) { return " " + msgName(id) + " " + id.toString(); } + +class ChartsWidget; +class ChartView { +public: + struct SigItem { + MessageId msg_id; + const cabana::Signal *sig = nullptr; + CabanaColor color; + bool visible = true; + std::vector vals; + std::vector step_vals; + ImPlotPoint track_pt{}; + SegmentTree segment_tree; + double min = 0; + double max = 0; + }; + + ChartView(const std::pair &x_range, ChartsWidget *parent); + void addSignal(const MessageId &msg_id, const cabana::Signal *sig); + bool hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const; + void updateSeries(const cabana::Signal *sig = nullptr, const MessageEventsMap *msg_new_events = nullptr); + void updatePlot(double cur, double min, double max); + void setSeriesType(SeriesType type) { series_type_ = type; } + void showTip(double sec); + void hideTip(); + void draw(float width); // one chart of settings.chart_height + void drawGhost(float width); // the same tile rendered again, without handling any input + void removeIf(std::function predicate); + void takeSignalsFrom(ChartView *source); + // every signal but the first, with its original color, for a split into one chart per signal + std::vector takeExtraSignals(); + void adoptSignal(SigItem s); + void setDropHighlight(bool highlight) { can_drop_ = highlight; } + const std::vector &signals() const { return sigs_; } + const ImRect &rect() const { return layout_.rect; } // the whole chart widget, screen coordinates + bool plotHovered() const { return layout_.plot_hovered; } + double secondsAtPoint(const ImVec2 &pt) const { + return x_min_ + (pt.x - layout_.plot_area.Min.x) * (x_max_ - x_min_) / std::max(layout_.plot_area.GetWidth(), 1.0f); + } + +private: + using PointIter = std::vector::const_iterator; + + void signalUpdated(const cabana::Signal *sig); + void manageSignals(); + 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; }); } + + void appendCanEvents(const cabana::Signal *sig, const std::vector &events, + std::vector &vals, std::vector &step_vals); + void createToolButtons(); + void drawContextMenu(); + void handleMousePress(); + void handleMouseMove(); + void handleMouseRelease(); + void updateLayout(); + void updateAxisY(); + void paint(); + void drawStaticLayer(); + void drawAxes(); + void drawLegend(); + void drawSeries(); + void drawForeground(); + void drawSignalValue(); + void drawTimeline(); + void drawRubberBandTimeRange(); + void drawMenuActions(); // the series type / manage / split entries shared by the menu button and the context menu + int xAxisPrecision() const; + std::tuple getNiceAxisNumbers(double min, double max, int tick_count); + double niceNumber(double x, bool ceiling); + CabanaColor uniqueColor(CabanaColor color, const cabana::Signal *exclude = nullptr) const; + // the last sample at or before sec, nullptr when there is none inside the visible range + const ImPlotPoint *lastPointBefore(const SigItem &s, double sec) const; + // the samples inside [x_min_, x_max_) + std::pair visibleRange(const std::vector &points) const; + inline void clearTrackPoints() { for (auto &s : sigs_) s.track_pt = {}; } + inline float xPos(double sec) const { return layout_.plot_area.Min.x + (sec - x_min_) / (x_max_ - x_min_) * layout_.plot_area.GetWidth(); } + inline float yPos(double val) const { return layout_.plot_area.Max.y - (val - y_min_) / (y_max_ - y_min_) * layout_.plot_area.GetHeight(); } + + // layout + struct Layout { + ImRect rect; // the whole chart widget, screen coordinates + ImRect plot_area; + ImRect move_icon_rect; + ImRect close_btn_rect; + ImRect manage_btn_rect; + std::vector legend_rects; + float header_bottom = 0; + bool plot_hovered = false; + } layout_; + // axes + double x_min_; + double x_max_; + double y_min_ = 0; + double y_max_ = 1; + int y_tick_count_ = 3; + int y_precision_ = 0; + std::string y_unit_; + // interaction + enum class MouseMode { None, Rubber, Scrub }; + MouseMode mouse_mode_ = MouseMode::None; + ImVec2 press_pos_; + ImRect rubber_rect_; + bool resume_after_scrub_ = false; + bool drawing_ghost_ = false; // drawing the drag preview: no mouse handling, no tip + ImGuiID context_menu_id_ = 0; + + TipLabel tip_label_; + std::vector sigs_; + double cur_sec_ = 0; + SeriesType series_type_ = SeriesType::Line; + bool can_drop_ = false; + double tooltip_x_ = -1; + ChartsWidget *charts_widget_; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc new file mode 100644 index 0000000000..546705d395 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -0,0 +1,674 @@ +#define IMGUI_DEFINE_MATH_OPERATORS // ImVec2 arithmetic, must precede imgui.h +#include "tools/cabana/ui/chart/chartswidget.h" + +#include "tools/cabana/ui/threadpool.h" + +#include +#include +#include +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/chart/chart.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +const int MAX_COLUMN_COUNT = 4; +const int CHART_SPACING = 4; +const int START_DRAG_DISTANCE = 10; +const float LAYOUT_HORIZONTAL_SPACING = 6.0f; +const float MIN_RANGE_SLIDER_WIDTH = 40.0f; + +bool LogSlider::draw(const char *label, float width) { + return fusionSliderInt(label, &pos_, min_, max_, width); +} + +ChartsWidget::ChartsWidget() { + range_slider_.setRange(1, settings.max_cached_minutes * 60); + + tabbar_.setAutoHide(true); + tabbar_.setUsesScrollButtons(true); + tabbar_.setTabsClosable(true); + + column_count_ = std::clamp(settings.chart_column_count, 1, MAX_COLUMN_COUNT); + max_chart_range_ = std::clamp(settings.chart_range, 1, settings.max_cached_minutes * 60); + display_range_ = std::make_pair(can->minSeconds(), can->minSeconds() + max_chart_range_); + range_slider_.setValue(max_chart_range_); + + connections_.push_back(dbc()->fileChanged.connect([this]() { removeAll(); })); + connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &events) { eventsMerged(events); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); + connections_.push_back(can->seeking.connect([this](double) { updateState(); })); + connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { updateState(); })); + connections_.push_back(settings.changed.connect([this]() { settingChanged(); })); + connections_.push_back(seriesChanged.connect([this]() { updateTabBar(); })); + connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { removeTab(index); })); + connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem("Close Other Tabs")) { + tabbar_.moveTab(index, 0); + tabbar_.setCurrentIndex(0); + while (tabbar_.count() > 1) removeTab(1); + } + ImGui::EndPopup(); + } + })); + connections_.push_back(tabbar_.currentChanged.connect([this](int index) { + if (index != -1) updateLayout(); + })); + + setIsDocked(true); + newTab(); +} + +ChartsWidget::~ChartsWidget() = default; + +std::string ChartsWidget::whatsThis() const { + return R"( + Chart View
+ Click: Click to seek to a corresponding time.
+ Drag: Zoom into the chart.
+ Shift + Drag: Scrub through the chart to view values.
+ Right Mouse: Open the context menu.
+ )"; +} + +void ChartsWidget::newTab() { + static int tab_unique_id = 0; + int idx = tabbar_.addTab(""); + tabbar_.setTabData(idx, tab_unique_id++); + tabbar_.setCurrentIndex(idx); + updateTabBar(); +} + +void ChartsWidget::removeTab(int index) { + int id = tabbar_.tabData(index); + for (auto &c : std::vector(tab_charts_[id])) { + removeChart(c); + } + tab_charts_.erase(id); + tabbar_.removeTab(index); + updateTabBar(); +} + +void ChartsWidget::updateTabBar() { + for (int i = 0; i < tabbar_.count(); ++i) { + const auto &charts_in_tab = tab_charts_[tabbar_.tabData(i)]; + tabbar_.setTabText(i, "Tab " + std::to_string(i + 1) + " (" + std::to_string((int)charts_in_tab.size()) + ")"); + } +} + +void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) { + std::vector> futures; + for (auto &c : charts_) { + futures.push_back(ThreadPool::instance().run([c = c.get(), &new_events]() { c->updateSeries(nullptr, &new_events); })); + } + for (auto &f : futures) f.get(); +} + +void ChartsWidget::zoomReset() { + can->setTimeRange(std::nullopt); + zoom_undo_stack_.clear(); +} + +ImRect ChartsWidget::chartVisibleRect(ChartView *chart) { + ImRect r = chart->rect(); + r.ClipWith(charts_scroll_viewport_); + return r; +} + +void ChartsWidget::showValueTip(double sec) { + if (chartDragActive()) sec = -1; // no value tip while a drag is in progress + showTip(sec); + if (sec < 0 && !value_tip_visible_) return; + + value_tip_visible_ = sec >= 0; + for (auto c : currentCharts()) { + value_tip_visible_ ? c->showTip(sec) : c->hideTip(); + } +} + +void ChartsWidget::updateState() { + if (charts_.empty()) return; + + const auto &time_range = can->timeRange(); + const double cur_sec = can->currentSec(); + if (!time_range.has_value()) { + double pos = (cur_sec - display_range_.first) / std::max(1.0, max_chart_range_); + if (pos < 0 || pos > 0.8) { + display_range_.first = std::max(can->minSeconds(), cur_sec - max_chart_range_ * 0.1); + } + double max_sec = std::min(display_range_.first + max_chart_range_, can->maxSeconds()); + display_range_.first = std::max(can->minSeconds(), max_sec - max_chart_range_); + display_range_.second = display_range_.first + max_chart_range_; + } + + const auto &range = time_range ? *time_range : display_range_; + for (auto &c : charts_) { + c->updatePlot(cur_sec, range.first, range.second); + } +} + +void ChartsWidget::setMaxChartRange(int value) { + max_chart_range_ = settings.chart_range = value; + updateState(); +} + +void ChartsWidget::setIsDocked(bool docked) { + is_docked_ = docked; + if (!docked) float_window_init_ = true; +} + +void ChartsWidget::drawToolBar() { + beginToolbar(); + float slider_width = 150.0f; + const bool is_zoomed = can->timeRange().has_value(); + + // the labels are captured by reference, they outlive the draw calls below + std::vector items; + items.push_back({toolbarButtonWidth(icon::PLUS_SQUARE), [this]() { + if (toolButton("new_plot_btn", icon::PLUS_SQUARE, "New Chart")) newChart(); + }}); + items.push_back({toolbarButtonWidth(icon::WINDOW_STACK), [this]() { + if (toolButton("new_tab_btn", icon::WINDOW_STACK, "New Tab")) newTab(); + }}); + const std::string title_label = "Charts: " + std::to_string(charts_.size()); + items.push_back({ImGui::CalcTextSize(title_label.c_str()).x + LAYOUT_HORIZONTAL_SPACING, [&title_label]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(title_label.c_str()); + ImGui::SameLine(0.0f, LAYOUT_HORIZONTAL_SPACING); + ImGui::Dummy(ImVec2(0.0f, 0.0f)); + }}); + + const int type_count = (int)std::size(SERIES_TYPE_NAMES); + const std::string chart_type_text = std::string("Type: ") + SERIES_TYPE_NAMES[std::clamp(settings.chart_series_type, 0, type_count - 1)]; + items.push_back({menuButtonWidth(chart_type_text), [this, &chart_type_text]() { + menuButton("chart_type", chart_type_text, "chart_type_menu"); + if (ImGui::BeginPopup("chart_type_menu")) { + for (int i = 0; i < type_count; ++i) { + if (ImGui::MenuItem(SERIES_TYPE_NAMES[i])) { + settings.chart_series_type = i; + settingChanged(); + } + } + ImGui::EndPopup(); + } + }}); + + const std::string columns_action_text = "Columns: " + std::to_string(column_count_); + if (columns_action_visible_) { + items.push_back({menuButtonWidth(columns_action_text), [this, &columns_action_text]() { + menuButton("columns", columns_action_text, "columns_menu"); + if (ImGui::BeginPopup("columns_menu")) { + for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { + if (ImGui::MenuItem(std::to_string(i + 1).c_str())) setColumnCount(i + 1); + } + ImGui::EndPopup(); + } + }}); + } + + // the spacer right aligns the rest + const size_t spacer_index = items.size(); + size_t slider_index = (size_t)-1; + const std::string range_lb = is_zoomed ? std::string() : utils::formatSeconds(max_chart_range_); + std::string reset_zoom_text; + if (!is_zoomed) { + items.push_back({ImGui::CalcTextSize(range_lb.c_str()).x, [&range_lb]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(range_lb.c_str()); + }}); + slider_index = items.size(); + items.push_back({slider_width, [this, &slider_width]() { + if (range_slider_.draw("##range_slider", slider_width)) setMaxChartRange(range_slider_.value()); + ImGui::SetItemTooltip("Set the chart range"); + }}); + } else { + char buf[64]; + snprintf(buf, sizeof(buf), "%.2f-%.2f", can->timeRange()->first, can->timeRange()->second); + reset_zoom_text = buf; + items.push_back({toolbarButtonWidth(icon::ARROW_COUNTERCLOCKWISE), [this]() { + ImGui::BeginDisabled(!zoom_undo_stack_.canUndo()); + if (toolButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); + ImGui::EndDisabled(); + }}); + items.push_back({toolbarButtonWidth(icon::ARROW_CLOCKWISE), [this]() { + ImGui::BeginDisabled(!zoom_undo_stack_.canRedo()); + if (toolButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); + ImGui::EndDisabled(); + }}); + items.push_back({toolbarButtonWidth(std::string(icon::ZOOM_OUT) + " " + reset_zoom_text), [this, &reset_zoom_text]() { + if (toolButton("reset_zoom_btn", icon::ZOOM_OUT, "Reset Zoom", reset_zoom_text.c_str())) zoomReset(); + }}); + } + items.push_back({toolbarButtonWidth(icon::X_SQUARE), [this]() { + ImGui::BeginDisabled(charts_.empty()); + if (toolButton("remove_all_btn", icon::X_SQUARE, "Remove all charts")) removeAll(); + ImGui::EndDisabled(); + }}); + const char *dock_btn_icon = is_docked_ ? icon::ARROW_UP_RIGHT_SQUARE : icon::ARROW_DOWN_LEFT_SQUARE; + items.push_back({toolbarButtonWidth(dock_btn_icon), [this, dock_btn_icon]() { + if (toolButton("dock_btn", dock_btn_icon, is_docked_ ? "Float the charts window" : "Dock the charts window")) toggleChartsDocking(); + }}); + + // the slider shrinks first, the buttons stay pinned to the right edge + if (slider_index != (size_t)-1) { + const float shrink = std::min(slider_width - MIN_RANGE_SLIDER_WIDTH, toolbarWidth(items, spacer_index) - ImGui::GetContentRegionAvail().x); + if (shrink > 0.0f) { + slider_width -= shrink; + items[slider_index].width = slider_width; + } + } + drawToolbar(items, spacer_index); + endToolbar(); +} + +void ChartsWidget::settingChanged() { + if (range_slider_.maximum() != settings.max_cached_minutes * 60) { + range_slider_.setRange(1, settings.max_cached_minutes * 60); + } + for (auto &c : charts_) { + c->setSeriesType((SeriesType)settings.chart_series_type); + } +} + +ChartView *ChartsWidget::findChart(const MessageId &id, const cabana::Signal *sig) { + for (auto &c : charts_) + if (c->hasSignal(id, sig)) return c.get(); + return nullptr; +} + +ChartView *ChartsWidget::createChart(int pos) { + auto chart = std::make_unique(can->timeRange().value_or(display_range_), this); + ChartView *ptr = chart.get(); + pos = std::clamp(pos, 0, (int)charts_.size()); + charts_.insert(charts_.begin() + pos, std::move(chart)); + currentCharts().insert(currentCharts().begin() + pos, ptr); + updateLayout(); + return ptr; +} + +void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { + ChartView *chart = findChart(id, sig); + if (show && !chart) { + chart = merge && currentCharts().size() > 0 ? currentCharts().front() : createChart(); + chart->addSignal(id, sig); + updateState(); + } else if (!show && chart) { + chart->removeIf([&](auto &s) { return s.msg_id == id && s.sig == sig; }); + } +} + +void ChartsWidget::splitChart(ChartView *src_chart) { + if (src_chart->signals().size() > 1) { + auto it = std::find_if(charts_.begin(), charts_.end(), [src_chart](auto &c) { return c.get() == src_chart; }); + const int pos = it - charts_.begin() + 1; + for (auto &s : src_chart->takeExtraSignals()) { + createChart(pos)->adoptSignal(std::move(s)); + } + updateState(); + } +} + +std::vector ChartsWidget::serializeChartIds() const { + std::vector chart_ids; + for (auto &c : charts_) { + std::string ids; + for (const auto &s : c->signals()) { + if (!ids.empty()) ids += ','; + ids += s.msg_id.toString() + "|" + s.sig->name; + } + chart_ids.push_back(ids); + } + std::reverse(chart_ids.begin(), chart_ids.end()); + return chart_ids; +} + +void ChartsWidget::restoreChartsFromIds(const std::vector &chart_ids) { + for (const auto &chart_id : chart_ids) { + int index = 0; + for (const auto &part : utils::split(chart_id, ',')) { + const size_t sep = part.find('|'); + if (sep == std::string::npos) continue; + MessageId msg_id = MessageId::fromString(part.substr(0, sep)); + if (auto *msg = dbc()->msg(msg_id)) + if (auto *sig = msg->sig(part.substr(sep + 1))) + showChart(msg_id, sig, true, index++ > 0); + } + } +} + +void ChartsWidget::setColumnCount(int n) { + n = std::clamp(n, 1, MAX_COLUMN_COUNT); + if (column_count_ != n) { + column_count_ = settings.chart_column_count = n; + updateLayout(); + } +} + +void ChartsWidget::updateLayout() { + // the container has not been drawn yet (docked/floated this frame): keep the last known layout + const float container_width = charts_container_.geometry().GetWidth(); + if (container_width <= 0) return; + + int n = MAX_COLUMN_COUNT; + for (; n > 1; --n) { + if ((n * CHART_MIN_WIDTH + (n - 1) * CHART_SPACING) < container_width) break; + } + + columns_action_visible_ = n > 1; + current_column_count_ = std::min(column_count_, n); +} + +void ChartsWidget::startChartDrag(ChartView *chart, const ImVec2 &global_pos) { + stopAutoScroll(); + drag_ = {.source = chart, .press_pos = global_pos}; + showValueTip(-1); // no value tip while a drag is in progress + // the drag preview re-renders the tile at CHART_MIN_WIDTH + drag_preview_size_ = ImVec2(CHART_MIN_WIDTH, (float)settings.chart_height); +} + +void ChartsWidget::dragChartMove(const ImVec2 &global_pos) { + if (!drag_.active) { + ImVec2 d = global_pos - drag_.press_pos; + if (std::abs(d.x) + std::abs(d.y) < START_DRAG_DISTANCE) return; + drag_.active = true; + drag_preview_visible_ = true; + } + drag_preview_pos_ = global_pos + ImVec2(5, 5); + + // hovering a tab switches to it so the chart can be dropped into another tab + int tab = tabbar_.tabAt(global_pos); + if (tab >= 0 && tab != tabbar_.currentIndex()) { + tabbar_.setCurrentIndex(tab); + } + + ChartView *target = nullptr; + for (auto c : currentCharts()) { + if (c != drag_.source && c->rect().Contains(global_pos)) { + target = c; + break; + } + } + if (std::exchange(drop_target_, target) != target) { + for (auto &c : charts_) c->setDropHighlight(c.get() == target); + } + bool in_viewport = charts_scroll_viewport_.Contains(global_pos); + bool on_background = !target && in_viewport && !charts_container_.childAt(global_pos); + charts_container_.setDropIndicator(on_background ? global_pos : ImVec2()); + + if (in_viewport) { + startAutoScroll(global_pos); + } +} + +void ChartsWidget::cancelChartDrag() { + drag_ = {}; + stopAutoScroll(); + drag_preview_visible_ = false; + charts_container_.setDropIndicator({}); + if (auto target = std::exchange(drop_target_, nullptr)) target->setDropHighlight(false); +} + +void ChartsWidget::dragChartRelease(const ImVec2 &global_pos) { + ChartView *source = drag_.source; + bool active = drag_.active; + ChartView *target = drop_target_; + cancelChartDrag(); + if (!active) return; + + bool in_viewport = charts_scroll_viewport_.Contains(global_pos); + if (target) { + // merge source into target + target->takeSignalsFrom(source); + } else if (in_viewport && !charts_container_.childAt(global_pos)) { + // reorder within the current tab + auto w = charts_container_.getDropAfter(global_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(); + updateTabBar(); + } + } +} + +void ChartsWidget::drawDragPreview() { + if (!drag_preview_visible_ || !drag_.source) return; + // the drag preview is the whole tile (header + axes + plot) at 50% alpha, re-rendered into a window that + // takes no input, so the live chart keeps handling the mouse. + ImGui::SetNextWindowPos(drag_preview_pos_); + ImGui::SetNextWindowSize(drag_preview_size_); + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.5f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking; + if (ImGui::Begin("##chart_drag_ghost", nullptr, flags)) { + drag_.source->drawGhost(drag_preview_size_.x); + } + ImGui::End(); + ImGui::PopStyleVar(3); +} + +void ChartsWidget::startAutoScroll(const ImVec2 &global_pos) { + auto_scroll_pos_ = global_pos; + if (!auto_scroll_timer_active_) auto_scroll_timer_next_ = ImGui::GetTime() + 0.05; + auto_scroll_timer_active_ = true; +} + +void ChartsWidget::stopAutoScroll() { + auto_scroll_timer_active_ = false; + auto_scroll_count_ = 0; +} + +void ChartsWidget::doAutoScroll() { + if (!charts_scroll_) return; + const int page_step = charts_scroll_viewport_.GetHeight(); + if (auto_scroll_count_ < page_step) { + ++auto_scroll_count_; + } + + int value = charts_scroll_->Scroll.y; + ImVec2 pos = auto_scroll_pos_; + ImRect area = charts_scroll_viewport_; + + int new_value = value; + if (pos.y - area.Min.y < settings.chart_height / 2) { + new_value = value - auto_scroll_count_; + } else if (area.Max.y - pos.y < settings.chart_height / 2) { + new_value = value + auto_scroll_count_; + } + new_value = std::clamp(new_value, 0, charts_scroll_->ScrollMax.y); + if (new_value != value) ImGui::SetScrollY(charts_scroll_, new_value); + if (value == new_value) { + stopAutoScroll(); + } else if (chartDragActive()) { + // refresh the drop indicator/target at the new scroll position + dragChartMove(auto_scroll_pos_); + } +} + +void ChartsWidget::newChart() { + execSignalSelector(std::make_unique("New Chart"), nullptr, [this](SignalSelector &dlg) { + const auto &items = dlg.selectedItems(); + if (!items.empty()) { + auto c = createChart(); + for (const auto &it : items) { + c->addSignal(it.msg_id, it.sig); + } + updateState(); + } + }); +} + +void ChartsWidget::execSignalSelector(std::unique_ptr dlg, ChartView *owner, std::function accepted) { + signal_selector_ = std::move(dlg); + signal_selector_owner_ = owner; + signal_selector_accepted_ = std::move(accepted); + signal_selector_->open(); +} + +void ChartsWidget::removeChart(ChartView *chart) { + if (drag_.source == chart) cancelChartDrag(); + if (drop_target_ == chart) drop_target_ = nullptr; + if (signal_selector_owner_ == chart) { + signal_selector_owner_ = nullptr; + signal_selector_accepted_ = nullptr; + } + auto it = std::find_if(charts_.begin(), charts_.end(), [chart](auto &c) { return c.get() == chart; }); + if (it != charts_.end()) { + deleted_charts_.push_back(std::move(*it)); // may be called from the chart's draw; freed next frame + charts_.erase(it); + } + for (auto &[_, list] : tab_charts_) { + list.erase(std::remove(list.begin(), list.end(), chart), list.end()); + } + updateLayout(); + seriesChanged(); +} + +void ChartsWidget::removeAll() { + while (tabbar_.count() > 1) { + tabbar_.removeTab(1); + } + std::vector all; + for (auto &c : charts_) all.push_back(c.get()); + for (auto c : all) removeChart(c); + tab_charts_.clear(); + zoomReset(); +} + +void ChartsWidget::handleEvents() { + // the mouse back button undoes a zoom; there is no swipe-back gesture + if (ImGui::IsMouseClicked(3) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows)) { + zoom_undo_stack_.undo(); + } + if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow)) { + if (chartDragActive()) cancelChartDrag(); + showValueTip(-1); + } + + // route all mouse events to the chart drag, even when the source chart is hidden by a tab switch + if (chartDragActive()) { + if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + dragChartMove(ImGui::GetMousePos()); + } else { + dragChartRelease(ImGui::GetMousePos()); + } + } + + if (!value_tip_visible_) return; + + // the tip is drawn on the foreground draw list, so the mouse is never "on the tip" + const ImVec2 delta = ImGui::GetIO().MouseDelta; + if (!any_plot_hovered_ && + (delta.x != 0 || delta.y != 0 || !ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows))) { + showValueTip(-1); // the mouse moved off the plot or out of the charts window + } +} + +void ChartsWidget::draw() { + deleted_charts_.clear(); + // the floating window is a top level window sized to its contents: keep it inside the main viewport so its + // toolbar stays reachable, then let the user resize it + if (float_window_init_ && !is_docked_) { + float_window_init_ = false; + const ImGuiViewport *viewport = ImGui::GetMainViewport(); + const ImVec2 size(viewport->WorkSize.x * 0.6f, viewport->WorkSize.y * 0.6f); + ImGui::SetWindowSize(size); + ImGui::SetWindowPos(viewport->WorkPos + (viewport->WorkSize - size) * 0.5f); + } + ImGui::PushID(this); + if (auto_scroll_timer_active_ && ImGui::GetTime() >= auto_scroll_timer_next_) { + auto_scroll_timer_next_ = ImGui::GetTime() + 0.05; + doAutoScroll(); + } + // the drop target and indicator must be resolved before the charts are painted, otherwise the highlight + // lags a frame behind the target used on release and the drop lands on the wrong chart + handleEvents(); + + drawToolBar(); + tabbar_.draw(); + + any_plot_hovered_ = false; + if (ImGui::BeginChild("charts_scroll", ImVec2(0, 0), ImGuiChildFlags_None, 0)) { + charts_scroll_ = ImGui::GetCurrentWindow(); + charts_scroll_viewport_ = charts_scroll_->InnerRect; + charts_container_.draw(); + } + ImGui::EndChild(); + + drawDragPreview(); + + if (signal_selector_ && !signal_selector_->draw()) { + auto dlg = std::move(signal_selector_); + auto accepted = std::move(signal_selector_accepted_); + signal_selector_owner_ = nullptr; + if (dlg->accepted() && accepted) accepted(*dlg); + } + ImGui::PopID(); +} + +void ChartsContainer::draw() { + ImGuiWindow *window = ImGui::GetCurrentWindow(); + const ImVec2 start = ImGui::GetCursorScreenPos(); + geometry_ = ImRect(start, start + ImVec2(window->InnerRect.GetWidth(), 0)); + charts_widget_->updateLayout(); + + const int n = std::max(charts_widget_->current_column_count_, 1); + const float spacing = CHART_SPACING; + const float width = (geometry_.GetWidth() - (n - 1) * spacing) / n; + const ImVec2 origin = ImGui::GetCursorScreenPos() + ImVec2(0, CHART_SPACING); + auto current_charts = charts_widget_->currentCharts(); // copy: drawing may remove charts + float bottom = origin.y; + const bool aligned = ImPlot::BeginAlignedPlots("charts_align", true); + for (int i = 0; i < current_charts.size(); ++i) { + ImVec2 pos = origin + ImVec2((i % n) * (width + spacing), (i / n) * (settings.chart_height + spacing)); + ImGui::SetCursorScreenPos(pos); + current_charts[i]->draw(width); + bottom = std::max(bottom, pos.y + settings.chart_height); + if (current_charts[i]->plotHovered()) charts_widget_->any_plot_hovered_ = true; // the window must be hovered too + } + if (aligned) ImPlot::EndAlignedPlots(); + ImGui::SetCursorScreenPos(ImVec2(origin.x, bottom)); + ImGui::Dummy(ImVec2(geometry_.GetWidth(), CHART_SPACING)); + geometry_.Max.y = bottom + CHART_SPACING; + drawDropIndicator(); +} + +void ChartsContainer::drawDropIndicator() { + if (!(drop_indicator_pos_.x == 0 && drop_indicator_pos_.y == 0) && !childAt(drop_indicator_pos_)) { + ImRect r = geometry_; + r.Max.y = r.Min.y + CHART_SPACING; + if (auto insert_after = getDropAfter(drop_indicator_pos_)) { + float h = r.GetHeight(); + r.Min.y = insert_after->rect().Max.y; + r.Max.y = r.Min.y + h; + } + + ImGui::GetWindowDrawList()->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_Header)); + } +} + +ChartView *ChartsContainer::getDropAfter(const ImVec2 &pos) const { + const auto &charts = charts_widget_->currentCharts(); + auto it = std::find_if(charts.crbegin(), charts.crend(), [&pos](auto c) { + const ImRect &area = c->rect(); + return pos.x >= area.Min.x && pos.x <= area.Max.x && pos.y >= area.Max.y; + }); + return it == charts.crend() ? nullptr : *it; +} + +ChartView *ChartsContainer::childAt(const ImVec2 &pos) const { + for (auto c : charts_widget_->currentCharts()) { + if (c->rect().Contains(pos)) return c; + } + return nullptr; +} diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.h b/openpilot/tools/cabana/ui/chart/chartswidget.h new file mode 100644 index 0000000000..c8c1ef7e13 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chartswidget.h @@ -0,0 +1,168 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/ui/chart/signalselector.h" +#include "tools/cabana/ui/widgets/tabbar.h" +#include "tools/cabana/commands.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/util.h" + +const int CHART_MIN_WIDTH = 300; + +// a slider whose value is mapped onto a log10 scale +class LogSlider { +public: + LogSlider(double factor) : scale_(factor) {} + + void setRange(double min, double max) { + scale_.setRange(min, max); + min_ = min; + max_ = max; + setValue(pos_); // the raw position is re-mapped as a value + } + int value() const { return scale_.value(pos_, minimum(), maximum()); } + void setValue(int v) { pos_ = scale_.position(v, minimum(), maximum()); } + int minimum() const { return min_; } + int maximum() const { return max_; } + bool draw(const char *label, float width); + +private: + LogScale scale_; + int min_ = 0; + int max_ = 1; + int pos_ = 0; +}; + +class ChartView; +class ChartsWidget; + +class ChartsContainer { +public: + ChartsContainer(ChartsWidget *parent) : charts_widget_(parent) {} + void setDropIndicator(const ImVec2 &pt) { drop_indicator_pos_ = pt; } + void draw(); // grid layout of the current tab's charts + ChartView *getDropAfter(const ImVec2 &pos) const; + ChartView *childAt(const ImVec2 &pos) const; + const ImRect &geometry() const { return geometry_; } // screen coordinates + +private: + void drawDropIndicator(); + + ImRect geometry_; + ChartsWidget *charts_widget_; + ImVec2 drop_indicator_pos_; +}; + +class ChartsWidget { +public: + ChartsWidget(); + ~ChartsWidget(); // out of line: the header users only see a forward declared ChartView + void draw(); // content only; MainWindow wraps it in a child region or the floating window + void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); + inline bool hasSignal(const MessageId &id, const cabana::Signal *sig) { return findChart(id, sig) != nullptr; } + std::vector serializeChartIds() const; + void restoreChartsFromIds(const std::vector &chart_ids); + std::string whatsThis() const; + + void setColumnCount(int n); + void removeAll(); + void setIsDocked(bool dock); + + Observable<> toggleChartsDocking; + Observable<> seriesChanged; + Observable showTip; + +private: + void handleEvents(); // the back button, focus loss, the chart drag and the value tip leave + void newChart(); + ChartView *createChart(int pos = 0); + void removeChart(ChartView *chart); + void splitChart(ChartView *chart); + ImRect chartVisibleRect(ChartView *chart); + void eventsMerged(const MessageEventsMap &new_events); + void updateState(); + void zoomReset(); + void startChartDrag(ChartView *chart, const ImVec2 &global_pos); + void dragChartMove(const ImVec2 &global_pos); + void dragChartRelease(const ImVec2 &global_pos); + void cancelChartDrag(); + bool chartDragActive() const { return drag_.source != nullptr; } + void startAutoScroll(const ImVec2 &global_pos); + void stopAutoScroll(); + void doAutoScroll(); + void drawToolBar(); + void updateTabBar(); + void setMaxChartRange(int value); + void updateLayout(); + void settingChanged(); + void showValueTip(double sec); + void newTab(); + void removeTab(int index); + inline std::vector ¤tCharts() { return tab_charts_[tabbar_.tabData(tabbar_.currentIndex())]; } + ChartView *findChart(const MessageId &id, const cabana::Signal *sig); + // draws the selector until closed, then runs `accepted` (unless `owner` was removed) + void execSignalSelector(std::unique_ptr dlg, ChartView *owner, std::function accepted); + void drawDragPreview(); + + LogSlider range_slider_{1000}; + bool is_docked_ = true; + bool float_window_init_ = false; // the floating window geometry is set once, right after undocking + + UndoStack zoom_undo_stack_; + + std::vector> charts_; + std::unordered_map> tab_charts_; + TabBar tabbar_; + ChartsContainer charts_container_{this}; + ImGuiWindow *charts_scroll_ = nullptr; // the scroll area child window + ImRect charts_scroll_viewport_; + int max_chart_range_ = 0; + std::pair display_range_; + bool columns_action_visible_ = false; + int column_count_ = 1; + int current_column_count_ = 0; + struct ChartDrag { + ChartView *source = nullptr; + ImVec2 press_pos; // global + bool active = false; + } drag_; + // the drag preview is a 50% alpha copy of the whole chart tile, drawn in a window that takes no input + ImVec2 drag_preview_pos_; + ImVec2 drag_preview_size_; + bool drag_preview_visible_ = false; + ChartView *drop_target_ = nullptr; + int auto_scroll_count_ = 0; + ImVec2 auto_scroll_pos_; + bool auto_scroll_timer_active_ = false; + double auto_scroll_timer_next_ = 0; + bool value_tip_visible_ = false; + bool any_plot_hovered_ = false; + std::vector> deleted_charts_; // freed at the start of the next draw() + std::unique_ptr signal_selector_; + ChartView *signal_selector_owner_ = nullptr; + std::function signal_selector_accepted_; + Connections connections_; + friend class ChartView; + friend class ChartsContainer; +}; + +class ZoomCommand : public UndoCommand { +public: + ZoomCommand(std::pair range) : range(range) { + prev_range = can->timeRange(); + } + void undo() override { can->setTimeRange(prev_range); } + void redo() override { can->setTimeRange(range); } + std::optional> prev_range, range; +}; diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc new file mode 100644 index 0000000000..f0f427222e --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -0,0 +1,155 @@ +#include "tools/cabana/ui/chart/signalselector.h" + +#include +#include + +#include "imgui.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/ui/chart/chart.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +SignalSelector::SignalSelector(std::string title) : title_(std::move(title)) { + for (const auto &[id, _] : can->lastMessages()) { + if (auto m = dbc()->msg(id)) { + msgs_combo_.push_back({m->name + " (" + id.toString() + ")", id}); + } + } + std::sort(msgs_combo_.begin(), msgs_combo_.end(), [](auto &a, auto &b) { return a.text < b.text; }); +} + +bool SignalSelector::draw() { + if (!open_) return false; + const std::string popup_id = title_ + "###SignalSelector"; + if (!show_) { + ImGui::OpenPopup(popup_id.c_str()); + show_ = true; + } + setNextDialogWindow(ImVec2(700.0f, 450.0f)); + if (!ImGui::BeginPopupModal(popup_id.c_str(), nullptr, ImGuiWindowFlags_NoSavedSettings)) { + open_ = false; + return false; + } + + const float btn_w = ImGui::GetFrameHeight() + 8.0f; + const float column_w = (ImGui::GetContentRegionAvail().x - btn_w - ImGui::GetStyle().ItemSpacing.x * 2) / 2; + // the selected list spans the combo row too; both lists end above the Ok/Cancel row + const float lists_h = ImGui::GetContentRegionAvail().y - ImGui::GetFrameHeightWithSpacing() * 3; + + ImGui::BeginGroup(); + ImGui::TextUnformatted("Available Signals"); + // a combo popup with a filter box + const char *preview = msgs_combo_index_ >= 0 ? msgs_combo_[msgs_combo_index_].text.c_str() : "Select a msg..."; + ImGui::SetNextItemWidth(column_w); + if (ImGui::BeginCombo("##msgs_combo", preview)) { + if (ImGui::IsWindowAppearing()) { + msgs_combo_filter_.clear(); // reopen showing the full list + ImGui::SetKeyboardFocusHere(); + } + ImGui::SetNextItemWidth(-FLT_MIN); + inputText("##msgs_filter", &msgs_combo_filter_, "Select a msg..."); + for (int i = 0; i < (int)msgs_combo_.size(); ++i) { + if (!msgs_combo_filter_.empty() && !utils::containsCI(msgs_combo_[i].text, msgs_combo_filter_)) continue; + if (ImGui::Selectable(msgs_combo_[i].text.c_str(), i == msgs_combo_index_)) { + msgs_combo_index_ = i; + updateAvailableList(i); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndCombo(); + } + bool add_dbl = false; + drawList("##available_list", available_list_, &available_row_, false, &add_dbl, ImVec2(column_w, lists_h)); + ImGui::EndGroup(); + + ImGui::SameLine(); + ImGui::BeginGroup(); + ImGui::Dummy(ImVec2(btn_w, (lists_h + ImGui::GetFrameHeightWithSpacing() * 2) / 2 - ImGui::GetFrameHeight())); + ImGui::BeginDisabled(available_row_ == -1); + bool add_clicked = ImGui::Button(icon::CHEVRON_RIGHT, ImVec2(btn_w, 0)); + ImGui::EndDisabled(); + ImGui::BeginDisabled(selected_row_ == -1); + bool remove_clicked = ImGui::Button(icon::CHEVRON_LEFT, ImVec2(btn_w, 0)); + ImGui::EndDisabled(); + ImGui::EndGroup(); + + ImGui::SameLine(); + ImGui::BeginGroup(); + ImGui::TextUnformatted("Selected Signals"); + bool remove_dbl = false; + drawList("##selected_list", selected_list_, &selected_row_, true, &remove_dbl, ImVec2(column_w, lists_h + ImGui::GetFrameHeightWithSpacing())); + bool rejected = false; + dialogButtons("OK", &accepted_, &rejected); + const bool done = accepted_ || rejected; + ImGui::EndGroup(); + + if ((add_dbl || add_clicked) && available_row_ >= 0 && available_row_ < (int)available_list_.size()) { + add(available_row_); + } else if ((remove_dbl || remove_clicked) && selected_row_ >= 0 && selected_row_ < (int)selected_list_.size()) { + remove(selected_row_); + } + + if (done) { + open_ = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + return open_; +} + +void SignalSelector::drawList(const char *id, std::vector &list, int *current_row, bool show_msg_name, bool *double_clicked, const ImVec2 &size) { + if (!ImGui::BeginListBox(id, size)) return; + for (int i = 0; i < (int)list.size(); ++i) { + const auto &item = list[i]; + ImGui::PushID(i); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + if (ImGui::Selectable("##item", i == *current_row)) *current_row = i; + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + *current_row = i; + *double_clicked = true; + } + // label: colored square, signal name, then the message name/id in gray + ImDrawList *dl = ImGui::GetWindowDrawList(); + float x = pos.x + 5; + drawColorMarker(dl, ImVec2(x, pos.y), toImU32(item.sig->color)); + x += markerSize() + 4; + dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(ImGuiCol_Text), item.sig->name.c_str()); + if (show_msg_name) { + x += ImGui::CalcTextSize(item.sig->name.c_str()).x; + dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(ImGuiCol_TextDisabled), msgLabel(item.msg_id).c_str()); + } + ImGui::PopID(); + } + ImGui::EndListBox(); +} + +void SignalSelector::add(int row) { + const auto &item = available_list_[row]; + selected_list_.emplace_back(item.msg_id, item.sig); + available_list_.erase(available_list_.begin() + row); + available_row_ = -1; +} + +void SignalSelector::remove(int row) { + const auto &item = selected_list_[row]; + if (msgs_combo_index_ >= 0 && item.msg_id == msgs_combo_[msgs_combo_index_].id) { + available_list_.emplace_back(item.msg_id, item.sig); + } + selected_list_.erase(selected_list_.begin() + row); + selected_row_ = -1; +} + +void SignalSelector::updateAvailableList(int index) { + if (index == -1) return; + available_list_.clear(); + available_row_ = -1; + MessageId msg_id = msgs_combo_[index].id; + for (auto s : dbc()->msg(msg_id)->getSignals()) { + bool is_selected = std::any_of(selected_list_.begin(), selected_list_.end(), + [sig = s, &msg_id](auto &it) { return it.msg_id == msg_id && it.sig == sig; }); + if (!is_selected) { + available_list_.emplace_back(msg_id, s); + } + } +} diff --git a/openpilot/tools/cabana/ui/chart/signalselector.h b/openpilot/tools/cabana/ui/chart/signalselector.h new file mode 100644 index 0000000000..675b6f0039 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/signalselector.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbcmanager.h" + +// non-blocking: open(), draw() every frame until it returns false, then check accepted() +class SignalSelector { +public: + struct ListItem { + ListItem(const MessageId &msg_id, const cabana::Signal *sig) : msg_id(msg_id), sig(sig) {} + MessageId msg_id; + const cabana::Signal *sig; + }; + + SignalSelector(std::string title); + const std::vector &selectedItems() const { return selected_list_; } + inline void addSelected(const MessageId &id, const cabana::Signal *sig) { selected_list_.emplace_back(id, sig); } + void open() { open_ = true; show_ = false; accepted_ = false; } + bool draw(); // false once the dialog is closed + bool accepted() const { return accepted_; } + +private: + void updateAvailableList(int index); + void add(int row); + void remove(int row); + void drawList(const char *id, std::vector &list, int *current_row, bool show_msg_name, bool *double_clicked, const ImVec2 &size); + + struct ComboItem { + std::string text; + MessageId id; + }; + std::string title_; + std::vector msgs_combo_; + int msgs_combo_index_ = -1; + std::string msgs_combo_filter_; + std::vector available_list_; + std::vector selected_list_; + int available_row_ = -1; + int selected_row_ = -1; + bool accepted_ = false; + bool open_ = false; + bool show_ = false; +}; diff --git a/openpilot/tools/cabana/ui/chart/sparkline.cc b/openpilot/tools/cabana/ui/chart/sparkline.cc new file mode 100644 index 0000000000..226483fd04 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/sparkline.cc @@ -0,0 +1,177 @@ +#include "tools/cabana/ui/chart/sparkline.h" + +#include +#include +#include + +#include "tools/cabana/ui/util.h" + +void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, ImVec2 sz, + double window_end) { + if (first == last || sz.x <= 0 || sz.y <= 0) { + render_points_.clear(); + size = {}; + return; + } + + points_.clear(); + min_val = std::numeric_limits::max(); + max_val = std::numeric_limits::lowest(); + points_.reserve(std::distance(first, last)); + + // x runs from the start of the time window, not from the first sample in it: the oldest sample drops + // out at its own rate, so anchoring to it slid the whole curve sideways by the sample spacing on every + // update and the sparkline jittered left and right instead of scrolling with the clock + const double window_start = window_end - range; + double value = 0.0; + for (auto it = first; it != last; ++it) { + if (sig->getValue((*it)->dat, (*it)->size, &value)) { + double x = can->toSeconds((*it)->mono_time) - window_start; + // the caller hands over a bit of data from before the window so the oldest samples walk out under + // the clip rect instead of vanishing at the left edge; they must not move the scale though + if (x >= 0.0) { + min_val = std::min(min_val, value); + max_val = std::max(max_val, value); + } + points_.push_back({x, value}); + } + } + if (min_val > max_val) { // nothing inside the window, the lead in is all there is + for (const auto &p : points_) { + min_val = std::min(min_val, p.y); + max_val = std::max(max_val, p.y); + } + } + + if (points_.empty()) { + render_points_.clear(); + size = {}; + return; + } + + freq_ = points_.size() / std::max(points_.back().x - points_.front().x, 1.0); + render(sig->color, range, sz, window_end); +} + +void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double window_end) { + bool is_flat_line = min_val == max_val; + if (is_flat_line) { + min_val -= 1.0; + max_val += 1.0; + } + + const double xscale = (sz.x - 1) / (double)range; + const double yscale = (sz.y - 3) / (max_val - min_val); + const double span = points_.back().x - points_.front().x; + bool draw_individual_points = (span * xscale / points_.size()) > 8.0; + + // transform or downsample the points + render_points_.reserve(points_.size()); + render_points_.clear(); + if (draw_individual_points) { + for (const auto &p : points_) { + render_points_.emplace_back(p.x * xscale, 1.0 + (max_val - p.y) * yscale); + } + } else if (is_flat_line) { + double y = sz.y / 2.0; + render_points_.emplace_back(points_.front().x * xscale, y); + render_points_.emplace_back(points_.back().x * xscale, y); + } else { + double prev_y = points_.front().y; + render_points_.emplace_back(points_.front().x * xscale, 1.0 + (max_val - prev_y) * yscale); + bool in_flat = false; + + for (size_t i = 1; i < points_.size(); ++i) { + const auto &p = points_[i]; + double y = p.y; + if (std::abs(y - prev_y) < 1e-6) { + in_flat = true; + } else { + if (in_flat) render_points_.emplace_back(points_[i - 1].x * xscale, 1.0 + (max_val - prev_y) * yscale); + render_points_.emplace_back(p.x * xscale, 1.0 + (max_val - y) * yscale); + in_flat = false; + } + prev_y = y; + } + if (in_flat) render_points_.emplace_back(points_.back().x * xscale, 1.0 + (max_val - prev_y) * yscale); + } + + size = sz; + CabanaColor line_color = color; + if (!isDarkTheme()) { + auto [h, s, v] = color.hsv(); + line_color = CabanaColor::fromHsv(h, std::min(1.0f, s * 2.0f), v * 0.7f, color.a / 255.0f); + } + color_ = toImU32(line_color); + draw_individual_points_ = draw_individual_points; + window_end_ = window_end; + xscale_ = xscale; +} + +void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos) const { + if (render_points_.empty()) return; + + // update() only runs when a message of this id arrives, so a slow message would hold the sparkline + // still for many frames and then move it in one step. scroll the rendered polyline by the time that + // has passed since it was built, which keeps the motion at the frame rate whatever the message rate is + const float shift = std::clamp((float)((can->currentSec() - window_end_) * xscale_), 0.0f, size.x); + + // physical pixels: the framebuffer is 2x the logical size on hidpi displays + const float px = 1.0f / std::max(1.0f, ImGui::GetIO().DisplayFramebufferScale.x); + auto snap = [&](float x) { return std::floor(x / px) * px; }; + + // a sample sits at t * xscale + k on screen, where k moves with the clock. snapping k to whole pixels + // keeps every sample's subpixel phase fixed to its timestamp, so the line looks identical from frame + // to frame and from update to update and only ever moves by whole pixels; moving it by fractions + // shifted the antialiasing coverage every frame and the thin peaks sparkled + ImVec2 offset(pos.x - shift, pos.y); + const double k = offset.x - (window_end_ * xscale_ - (size.x - 1)); + offset.x += snap(k) - k; + auto point_at = [&](const ImVec2 &p) { return ImVec2(offset.x + p.x, offset.y + p.y); }; + + draw_list->PushClipRect(pos, ImVec2(pos.x + size.x, pos.y + size.y), true); + + // a point is a 3x3 square + auto draw_point = [&](const ImVec2 &p) { draw_list->AddRectFilled(ImVec2(p.x - 1.5f, p.y - 1.5f), ImVec2(p.x + 1.5f, p.y + 1.5f), color_); }; + + if (draw_individual_points_) { + for (const auto &p : render_points_) { + draw_list->PathLineTo(point_at(p)); + draw_point(point_at(p)); + } + draw_list->PathStroke(color_, ImDrawFlags_None, 1.5f); + } else { + // one sample per pixel column: several strokes in a column overlap into a blur, and a dense + // high-contrast texture scrolling by is hard on the eyes + std::vector pts; + pts.reserve(render_points_.size()); + float col = -1e9f; + for (const auto &p : render_points_) { + ImVec2 sp = point_at(p); + float c = snap(sp.x); + if (c != col) { + pts.push_back(sp); + col = c; + } + } + + // antialiasing smooths the gentle slopes but smears the near-vertical segments of a spiky signal + // over neighboring columns, so those are drawn aliased. runs of one kind are stroked together and + // share their end points with the next run + auto steep = [&](size_t i) { return std::abs(pts[i + 1].y - pts[i].y) > 2.0f * std::abs(pts[i + 1].x - pts[i].x) + px; }; + const ImDrawListFlags saved = draw_list->Flags; + size_t i = 0; + while (i + 1 < pts.size()) { + const bool is_steep = steep(i); + size_t j = i + 1; + while (j + 1 < pts.size() && steep(j) == is_steep) ++j; + draw_list->Flags = is_steep ? (saved & ~ImDrawListFlags_AntiAliasedLines) : saved; + for (size_t n = i; n <= j; ++n) draw_list->PathLineTo(pts[n]); + draw_list->PathStroke(color_, ImDrawFlags_None, 1.0f); + i = j; + } + draw_list->Flags = saved; + draw_point(point_at(render_points_.back())); + } + draw_list->PopClipRect(); +} diff --git a/openpilot/tools/cabana/ui/chart/sparkline.h b/openpilot/tools/cabana/ui/chart/sparkline.h new file mode 100644 index 0000000000..bfee34c9ee --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/sparkline.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbc.h" +#include "tools/cabana/streams/abstractstream.h" + +class Sparkline { +public: + void update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, ImVec2 sz, double window_end); + inline double freq() const { return freq_; } + bool isEmpty() const { return render_points_.empty(); } + // emits the rendered polyline at pos (top-left, screen coordinates) + void draw(ImDrawList *draw_list, ImVec2 pos) const; + + ImVec2 size = {}; // empty when isEmpty() + double min_val = 0; + double max_val = 0; + +private: + struct Point { + double x, y; + }; + void render(const CabanaColor &color, int range, ImVec2 sz, double window_end); + + std::vector points_; + std::vector render_points_; + ImU32 color_ = 0; + double window_end_ = 0; // the time the polyline was built for, so draw() can scroll it on from there + double xscale_ = 0; + bool draw_individual_points_ = false; + double freq_ = 0; +}; diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.cc b/openpilot/tools/cabana/ui/chart/tiplabel.cc new file mode 100644 index 0000000000..896abb7479 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/tiplabel.cc @@ -0,0 +1,69 @@ +#define IMGUI_DEFINE_MATH_OPERATORS // ImVec2 arithmetic, must precede imgui.h +#include "tools/cabana/ui/chart/tiplabel.h" + +#include +#include + +#include "tools/cabana/ui/util.h" + +ImVec2 TipLabel::layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) const { + ImFont *bold = boldFont(); + const float font_size = ImGui::GetFontSize(); + const float line_height = ImGui::GetTextLineHeight(); + ImVec2 size(0, 0); + float y = origin.y; + for (const auto &line : text_) { + float x = origin.x; + if (line.has_marker) { + if (p) drawColorMarker(p, ImVec2(x, y), line.marker); + x += markerSize() + 4; + } + if (p) p->AddText(ImVec2(x, y), fg, line.name.c_str()); + x += ImGui::CalcTextSize(line.name.c_str()).x; + if (!line.bold.empty()) { + if (p) p->AddText(bold, font_size, ImVec2(x, y), fg, line.bold.c_str()); + x += bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, line.bold.c_str()).x; + } + if (p) p->AddText(ImVec2(x, y), fg, line.rest.c_str()); + x += ImGui::CalcTextSize(line.rest.c_str()).x; + size.x = std::max(size.x, x - origin.x); + y += line_height; + } + size.y = y - origin.y; + return size; +} + +ImVec2 TipLabel::sizeHint() const { + return layoutLines(nullptr, ImVec2(0, 0), 0) + ImVec2(MARGIN * 2, MARGIN * 2); +} + +void TipLabel::showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect) { + text_ = text; + if (!text_.empty()) { + ImVec2 extra(1, 1); + size_ = sizeHint() + extra; + ImVec2 tip_pos(pt.x + 8, rect.Min.y + 2); + if (tip_pos.x + size_.x >= rect.Max.x) { + tip_pos.x = pt.x - size_.x - 8; + } + if (rect.Contains(ImRect(tip_pos, tip_pos + size_))) { + pos_ = tip_pos; + visible_ = true; + return; + } + } + visible_ = false; +} + +void TipLabel::draw() { + if (!visible_) return; + + ImDrawList *p = ImGui::GetForegroundDrawList(); + const bool dark = isDarkTheme(); + const ImU32 bg = dark ? ImGui::GetColorU32(ImGuiCol_PopupBg) : ImGui::GetColorU32(ImGuiCol_ChildBg); + const ImU32 fg = dark ? ImGui::GetColorU32(ImGuiCol_Text) : IM_COL32(0x40, 0x40, 0x44, 0xff); + // filled panel with a 1px frame + p->AddRectFilled(pos_, pos_ + size_, bg); + p->AddRect(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_Border)); + layoutLines(p, pos_ + ImVec2(MARGIN, MARGIN), fg); +} diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.h b/openpilot/tools/cabana/ui/chart/tiplabel.h new file mode 100644 index 0000000000..6aa80538e8 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/tiplabel.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +// one line of the tip: [square] name value (min, max) +struct TipLine { + bool has_marker = false; + ImU32 marker = 0; + std::string name; + std::string bold; + std::string rest; +}; + +class TipLabel { +public: + void showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect); + void hide() { visible_ = false; } + bool isVisible() const { return visible_; } + void draw(); // draws the tip on the foreground draw list; call once per frame + +private: + // lays the lines out from origin, drawing them when p is given; returns the size of the text block + ImVec2 layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) const; + ImVec2 sizeHint() const; + + static constexpr float MARGIN = 2.0f; // 1 + PM_ToolTipLabelFrameWidth + std::vector text_; + ImVec2 pos_; + ImVec2 size_; + bool visible_ = false; +}; diff --git a/openpilot/tools/cabana/ui/dialogs/filedialog.cc b/openpilot/tools/cabana/ui/dialogs/filedialog.cc new file mode 100644 index 0000000000..5721d8edbc --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/filedialog.cc @@ -0,0 +1,222 @@ +#include "tools/cabana/ui/dialogs/filedialog.h" + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" + +namespace fs = std::filesystem; + +namespace FileDialog { + +namespace { + +// case insensitive, numeric aware collation ("mazda_3_2019" before "mazda_2017"): punctuation is ignored at +// the first level ("FORD_CADS_64" before "FORD_CADS.dbc"), digit runs compare numerically, case is ignored; +// ties fall back to a plain comparison +bool naturalLess(const std::string &a, const std::string &b) { + auto skip = [](const std::string &s, size_t &i) { + while (i < s.size() && !isalnum(static_cast(s[i]))) ++i; + }; + size_t i = 0, j = 0; + for (;;) { + skip(a, i); + skip(b, j); + if (i >= a.size() || j >= b.size()) break; + if (isdigit(static_cast(a[i])) && isdigit(static_cast(b[j]))) { + size_t ie = i, je = j; + while (ie < a.size() && isdigit(static_cast(a[ie]))) ++ie; + while (je < b.size() && isdigit(static_cast(b[je]))) ++je; + const unsigned long long na = std::stoull(a.substr(i, ie - i)), nb = std::stoull(b.substr(j, je - j)); + if (na != nb) return na < nb; + i = ie; + j = je; + } else { + const int ca = tolower(static_cast(a[i])), cb = tolower(static_cast(b[j])); + if (ca != cb) return ca < cb; + ++i; + ++j; + } + } + const bool a_done = i >= a.size(), b_done = j >= b.size(); + if (a_done != b_done) return a_done; + return a < b; +} + +enum class Mode { OpenFile, SaveFile, Directory }; + +struct State { + bool active = false; + Mode mode = Mode::OpenFile; + std::string title; + std::string extension; + fs::path dir; + std::string dir_input; + std::string filename; + std::vector entries; + Callback callback; +}; + +State g_state; +PopupOwner g_owner; + +void listDir() { + State &s = g_state; + s.entries.clear(); + std::error_code ec; + for (const auto &entry : fs::directory_iterator(s.dir, ec)) { + const std::string name = entry.path().filename().string(); + if (name.empty() || name[0] == '.') continue; + const bool is_dir = entry.is_directory(ec); + if (!is_dir && s.mode == Mode::Directory) continue; + if (!is_dir && !s.extension.empty() && entry.path().extension() != s.extension) continue; + s.entries.push_back(entry); + } + std::sort(s.entries.begin(), s.entries.end(), [](const auto &a, const auto &b) { + std::error_code sort_ec; + const bool da = a.is_directory(sort_ec), db = b.is_directory(sort_ec); + return da != db ? da : naturalLess(a.path().filename().string(), b.path().filename().string()); + }); + s.dir_input = s.dir.string(); +} + +void setDir(const fs::path &dir) { + std::error_code ec; + fs::path d = fs::is_directory(dir, ec) ? fs::absolute(dir, ec) : fs::current_path(ec); + g_state.dir = d.lexically_normal(); + listDir(); +} + +void start(Mode mode, const std::string &title, const fs::path &dir, const std::string &filename, + const std::string &extension, Callback cb) { + State &s = g_state; + s = State{}; + s.active = true; + s.mode = mode; + s.title = title; + s.extension = extension; + s.filename = filename; + s.callback = std::move(cb); + g_owner.reset(); + setDir(dir); +} + +void finish(const std::string &path) { + Callback cb = std::move(g_state.callback); + g_state = State{}; + g_owner.reset(); + if (cb) cb(path); +} + +void accept(const fs::path &path) { + if (g_state.mode == Mode::SaveFile) { + std::error_code ec; + if (fs::exists(path, ec)) { + const std::string name = path.filename().string(); + MessageBox::question(g_state.title, name + " already exists.\nDo you want to replace it?", [path](bool ok) { + if (ok) finish(path.string()); + }); + return; + } + } + finish(path.string()); +} + +} // namespace + +void getOpenFileName(const std::string &title, const std::string &dir, const std::string &extension, Callback cb) { + start(Mode::OpenFile, title, dir, "", extension, std::move(cb)); +} + +void getSaveFileName(const std::string &title, const std::string &default_path, const std::string &extension, Callback cb) { + const fs::path p(default_path); + start(Mode::SaveFile, title, p.parent_path(), p.filename().string(), extension, std::move(cb)); +} + +void getExistingDirectory(const std::string &title, const std::string &dir, Callback cb) { + start(Mode::Directory, title, dir, "", "", std::move(cb)); +} + +void draw() { + State &s = g_state; + if (!s.active) return; + const std::string popup_id = s.title + "###FileDialog"; + if (!beginDialog(popup_id.c_str(), &g_owner, ImVec2(640.0f, 480.0f), 0)) return; + + if (ImGui::Button("Up")) setDir(s.dir.parent_path()); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + if (inputText("##dir", &s.dir_input, "", ImGuiInputTextFlags_EnterReturnsTrue)) setDir(s.dir_input); + + const float footer = ImGui::GetFrameHeightWithSpacing() * (s.mode == Mode::Directory ? 1.0f : 2.0f) + ImGui::GetStyle().ItemSpacing.y; + bool ok = false, cancel = false; + fs::path result, pending_dir; + ImGui::BeginChild("entries", ImVec2(0, -footer), ImGuiChildFlags_Borders); + std::error_code dir_ec; + for (size_t i = 0; i < s.entries.size(); ++i) { + const auto &entry = s.entries[i]; + const bool is_dir = entry.is_directory(dir_ec); + const std::string name = entry.path().filename().string(); + const std::string label = (is_dir ? std::string(icon::FOLDER) : std::string(icon::FILE_EARMARK)) + " " + name; + ImGui::PushID(static_cast(i)); + const bool selected = !is_dir && name == s.filename; + if (ImGui::Selectable(label.c_str(), selected, ImGuiSelectableFlags_AllowDoubleClick)) { + const bool double_clicked = ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left); + if (is_dir) { + if (double_clicked) { + pending_dir = entry.path(); + } else if (s.mode == Mode::Directory) { + s.filename = name; + } + } else { + s.filename = name; + if (double_clicked && s.mode == Mode::OpenFile) { + result = entry.path(); + ok = true; + } + } + } + ImGui::PopID(); + if (ok || !pending_dir.empty()) break; + } + ImGui::EndChild(); + if (!pending_dir.empty()) setDir(pending_dir); + + if (s.mode != Mode::Directory) { + ImGui::SetNextItemWidth(-90.0f); + if (inputText("##name", &s.filename, "File name", ImGuiInputTextFlags_EnterReturnsTrue)) ok = true; + ImGui::SameLine(); + ImGui::TextDisabled("%s", s.extension.empty() ? "*" : ("*" + s.extension).c_str()); + } + const char *accept_label = s.mode == Mode::SaveFile ? "Save" : (s.mode == Mode::Directory ? "Choose" : "Open"); + dialogButtons(accept_label, &ok, &cancel); + + if (ok && result.empty()) { + if (s.mode == Mode::Directory) { + result = s.filename.empty() ? s.dir : s.dir / s.filename; + } else if (!s.filename.empty()) { + result = fs::path(s.filename).is_absolute() ? fs::path(s.filename) : s.dir / s.filename; + if (s.mode == Mode::SaveFile && !s.extension.empty() && result.extension().empty()) result += s.extension; + if (s.mode == Mode::OpenFile && !fs::is_regular_file(result, dir_ec)) ok = false; + } else { + ok = false; + } + } + if (ok || cancel) ImGui::CloseCurrentPopup(); + // nested so the overwrite prompt stacks on this dialog; it may finish the dialog through accept() + MessageBox::draw(); + if (!s.active) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (cancel) { + finish(""); + } else if (ok) { + accept(result); + } +} + +} // namespace FileDialog diff --git a/openpilot/tools/cabana/ui/dialogs/filedialog.h b/openpilot/tools/cabana/ui/dialogs/filedialog.h new file mode 100644 index 0000000000..529f223179 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/filedialog.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include +#include + +// file browser. One dialog at a time; the callback gets an empty path on cancel. +namespace FileDialog { + +using Callback = std::function; + +void getOpenFileName(const std::string &title, const std::string &dir, const std::string &extension, Callback cb); +void getSaveFileName(const std::string &title, const std::string &default_path, const std::string &extension, Callback cb); +void getExistingDirectory(const std::string &title, const std::string &dir, Callback cb); + +void draw(); // once per frame, at the top popup level + +} // namespace FileDialog diff --git a/openpilot/tools/cabana/ui/dialogs/messagebox.cc b/openpilot/tools/cabana/ui/dialogs/messagebox.cc new file mode 100644 index 0000000000..9d1a2bde97 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/messagebox.cc @@ -0,0 +1,87 @@ +#include "tools/cabana/ui/dialogs/messagebox.h" + +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/ui/util.h" + +namespace MessageBox { + +namespace { + +struct Box { + std::string title; + std::string text; + std::string detailed_text; + bool has_cancel = false; + std::function on_result; +}; + +std::deque g_queue; +bool g_show_details = false; +PopupOwner g_owner; + +void push(Box box) { g_queue.push_back(std::move(box)); } + +std::function wrap(std::function on_close) { + if (!on_close) return nullptr; + return [on_close = std::move(on_close)](bool) { on_close(); }; +} + +} // namespace + +void information(const std::string &title, const std::string &text, std::function on_close) { + push({.title = title, .text = text, .on_result = wrap(std::move(on_close))}); +} + +void warning(const std::string &title, const std::string &text, const std::string &detailed_text, + std::function on_close) { + push({.title = title, .text = text, .detailed_text = detailed_text, .on_result = wrap(std::move(on_close))}); +} + +void question(const std::string &title, const std::string &text, std::function on_result) { + push({.title = title, .text = text, .has_cancel = true, .on_result = std::move(on_result)}); +} + +void draw() { + if (g_queue.empty()) return; + Box &box = g_queue.front(); + const std::string popup_id = box.title + "###MessageBox"; + const bool first = g_owner.popup_id == 0; + if (!g_owner.begin(popup_id.c_str())) return; + // AlwaysAutoResize sizes the popup from its contents; keep the title bar text from being clipped + const ImGuiStyle &style = ImGui::GetStyle(); + const float min_width = ImGui::CalcTextSize(box.title.c_str()).x + style.FramePadding.x * 2 + style.WindowPadding.x * 2; + ImGui::SetNextWindowSizeConstraints(ImVec2(min_width, 0.0f), ImVec2(FLT_MAX, FLT_MAX)); + setNextDialogWindow(ImVec2(0.0f, 0.0f)); + if (!ImGui::BeginPopupModal(popup_id.c_str(), nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings)) return; + if (first) g_show_details = false; + bool result = false, done = false; + ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + 480.0f); + ImGui::TextUnformatted(box.text.c_str()); + ImGui::PopTextWrapPos(); + if (g_show_details) { + ImGui::InputTextMultiline("##details", box.detailed_text.data(), box.detailed_text.size() + 1, + ImVec2(480.0f, 160.0f), ImGuiInputTextFlags_ReadOnly); + } + ImGui::Separator(); + if (!box.detailed_text.empty()) { + // the details button sits at the left of the button box + if (ImGui::Button(g_show_details ? "Hide Details..." : "Show Details...")) g_show_details = !g_show_details; + ImGui::SameLine(); + } + dialogButtons("OK", &result, &done, true, box.has_cancel ? "Cancel" : nullptr); + if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) result = true; + if (result) done = true; + if (done) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (done) { + g_owner.reset(); + Box finished = std::move(g_queue.front()); + g_queue.pop_front(); + if (finished.on_result) finished.on_result(result); + } +} + +} // namespace MessageBox diff --git a/openpilot/tools/cabana/ui/dialogs/messagebox.h b/openpilot/tools/cabana/ui/dialogs/messagebox.h new file mode 100644 index 0000000000..1e30d33ddb --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/messagebox.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +// Boxes are queued and shown one at a time as modal popups; the caller passes a continuation where it +// needs the answer. +namespace MessageBox { + +// on_close (optional) runs when the box is dismissed +void information(const std::string &title, const std::string &text, std::function on_close = nullptr); +void warning(const std::string &title, const std::string &text, const std::string &detailed_text = "", + std::function on_close = nullptr); +// Ok | Cancel; on_result(true) for Ok +void question(const std::string &title, const std::string &text, std::function on_result); + +void draw(); // once per frame, at the top popup level + +} // namespace MessageBox diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc new file mode 100644 index 0000000000..d0676ae709 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -0,0 +1,124 @@ +#include "tools/cabana/ui/dialogs/routesdialog.h" + +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +namespace { +const char *PERIOD_NAMES[] = {"Last week", "Last 2 weeks", "Last month", "Last 6 months", "Preserved"}; +const int PERIOD_DAYS[] = {7, 14, 30, 180, -1}; +} // namespace + +void RoutesDialog::open(std::function on_done) { + on_done_ = std::move(on_done); + open_ = true; + popup_.reset(); + s_ = State{}; + alive_ = std::make_shared(true); + + routes::fetchDevices([this, alive = std::weak_ptr(alive_)](std::vector devices, bool success, int error_code) { + utils::runOnMainThread(utils::guarded(alive.lock(), [this, devices = std::move(devices), success, error_code]() { + setDeviceList(devices, success, error_code); + })); + }); +} + +void RoutesDialog::setDeviceList(const std::vector &devices, bool success, int error_code) { + if (success) { + s_.devices.clear(); + for (const auto &device : devices) s_.devices.push_back(device.dongle_id); + s_.devices_loaded = true; + s_.device_index = 0; + fetchRoutes(); + } else { + // the box shows on top of the dialog, which is rejected once the box is dismissed + MessageBox::warning("Error", error_code == 401 ? "Unauthorized. Authenticate with openpilot/tools/lib/auth.py" : "Network error", "", + utils::guarded(alive_, [this]() { finish(false); })); + } +} + +void RoutesDialog::fetchRoutes() { + if (!s_.devices_loaded || s_.devices.empty()) return; + + s_.routes.clear(); + s_.route_index = -1; + s_.empty_text = "Loading..."; + + const int request_id = ++s_.fetch_id; + auto on_routes = [this, alive = std::weak_ptr(alive_), request_id](std::vector list, bool success, int) { + utils::runOnMainThread(utils::guarded(alive.lock(), [this, list = std::move(list), success, request_id]() { + if (s_.fetch_id == request_id) setRouteList(list, success); + })); + }; + routes::fetchRoutes(s_.devices[s_.device_index], PERIOD_DAYS[s_.period_index], std::move(on_routes)); +} + +void RoutesDialog::setRouteList(const std::vector &list, bool success) { + if (success) { + for (const auto &route : list) { + const int mins = static_cast((route.end_ms - route.start_ms) / 60000); + s_.routes.push_back({routes::formatUnixMs(route.start_ms) + " " + std::to_string(mins) + "min", route.name}); + } + if (!s_.routes.empty()) s_.route_index = 0; + } else { + MessageBox::warning("Error", "Failed to fetch routes. Check your network connection.", "", + utils::guarded(alive_, [this]() { finish(false); })); + } + s_.empty_text = "No items"; +} + +void RoutesDialog::finish(bool accepted) { + alive_.reset(); + open_ = false; + auto on_done = std::move(on_done_); + if (on_done) on_done(accepted, accepted && s_.route_index >= 0 ? s_.routes[s_.route_index].name : ""); +} + +void RoutesDialog::draw() { + if (!open_) return; + if (!beginDialog("Remote routes", &popup_, ImVec2(480.0f, 420.0f))) return; + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Device"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + if (s_.devices_loaded) { + if (comboBox("##device", &s_.device_index, s_.devices)) fetchRoutes(); + } else { + int idx = 0; + ImGui::BeginDisabled(); + comboBox("##device", &idx, {"Loading..."}); + ImGui::EndDisabled(); + } + ImGui::SetNextItemWidth(-1.0f); + if (ImGui::Combo("##period", &s_.period_index, PERIOD_NAMES, IM_ARRAYSIZE(PERIOD_NAMES))) fetchRoutes(); + + bool accepted = false, rejected = false; + const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; + ImGui::BeginChild("routes", ImVec2(0, -footer), ImGuiChildFlags_Borders); + if (s_.routes.empty()) { + const ImVec2 size = ImGui::CalcTextSize(s_.empty_text.c_str()); + const ImVec2 avail = ImGui::GetContentRegionAvail(); + ImGui::SetCursorPos(ImVec2((avail.x - size.x) * 0.5f, (avail.y - size.y) * 0.5f)); + ImGui::TextUnformatted(s_.empty_text.c_str()); + } + for (int i = 0; i < static_cast(s_.routes.size()); ++i) { + ImGui::PushID(i); + if (ImGui::Selectable(s_.routes[i].label.c_str(), s_.route_index == i, ImGuiSelectableFlags_AllowDoubleClick)) { + s_.route_index = i; + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) accepted = true; + } + ImGui::PopID(); + } + ImGui::EndChild(); + + dialogButtons("OK", &accepted, &rejected); + MessageBox::draw(); + if (accepted || rejected || !open_) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (accepted || rejected) finish(accepted); +} diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.h b/openpilot/tools/cabana/ui/dialogs/routesdialog.h new file mode 100644 index 0000000000..8f84096973 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/routes.h" +#include "tools/cabana/ui/util.h" + +// "Remote routes" browser. on_done gets accepted=true with the selected route name ("" if none), accepted=false on cancel. +class RoutesDialog { +public: + void open(std::function on_done); + void draw(); + +private: + void setDeviceList(const std::vector &devices, bool success, int error_code); + void setRouteList(const std::vector &list, bool success); + void fetchRoutes(); + void finish(bool accepted); + + struct RouteItem { + std::string label; + std::string name; + }; + + struct State { + bool devices_loaded = false; + std::vector devices; + int device_index = 0; + int period_index = 0; + std::vector routes; + int route_index = -1; + std::string empty_text = "No items"; + int fetch_id = 0; // the reply of an older request is dropped + }; + + bool open_ = false; + PopupOwner popup_; + State s_; + std::function on_done_; + // created by open() and reset by finish(); guards main-thread callbacks from detached worker threads + std::shared_ptr alive_; +}; diff --git a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc new file mode 100644 index 0000000000..ea82a62a8d --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc @@ -0,0 +1,108 @@ +#include "tools/cabana/ui/dialogs/settingsdialog.h" + +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +namespace { + +const int MIN_CACHE_MINUTES = 30; +const int MAX_CACHE_MINUTES = 120; + +// the label sits in the left column, the field in the right one, all fields aligned +enum FormLabel { THEME, CACHED_MINUTES, DRAG_DIRECTION, CHART_HEIGHT, FORM_LABEL_COUNT }; +const char *FORM_LABELS[FORM_LABEL_COUNT] = {"Color Theme", "Max Cached Minutes", "Drag Direction", "Chart Height"}; + +float formLabelWidth() { + float w = 0.0f; + for (const char *label : FORM_LABELS) w = std::max(w, ImGui::CalcTextSize(label).x); + return w + ImGui::GetStyle().ItemSpacing.x * 2; // horizontal spacing between label and field +} + +void formRow(FormLabel label, float label_width) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(FORM_LABELS[label]); + ImGui::SameLine(label_width); + ImGui::SetNextItemWidth(-FLT_MIN); +} + +} // namespace + +void SettingsDialog::open() { + theme_ = settings.theme; + cached_minutes_ = settings.max_cached_minutes; + drag_direction_ = settings.drag_direction; + chart_height_ = settings.chart_height; + log_livestream_ = settings.log_livestream; + log_path_ = settings.log_path; + open_ = true; + popup_.reset(); +} + +void SettingsDialog::draw() { + if (!open_) return; + if (!beginDialog("Settings", &popup_, ImVec2(400.0f, 0.0f))) return; + const float label_width = formLabelWidth(); + + ImGui::SeparatorText("General"); + static const char *themes[] = {"Light", "Dark"}; + formRow(THEME, label_width); + int theme_index = theme_ - LIGHT_THEME; + if (ImGui::Combo("##theme", &theme_index, themes, IM_ARRAYSIZE(themes))) theme_ = theme_index + LIGHT_THEME; + formRow(CACHED_MINUTES, label_width); + // InputInt takes no character filter, so out of range text is clamped after the edit + if (ImGui::InputInt("##cached_minutes", &cached_minutes_, 1, 10)) { + cached_minutes_ = std::clamp(cached_minutes_, MIN_CACHE_MINUTES, MAX_CACHE_MINUTES); + } + + ImGui::SeparatorText("New Signal Settings"); + static const char *directions[] = {"MSB First", "LSB First", "Always Little Endian", "Always Big Endian"}; + formRow(DRAG_DIRECTION, label_width); + ImGui::Combo("##drag_direction", &drag_direction_, directions, IM_ARRAYSIZE(directions)); + + ImGui::SeparatorText("Chart"); + formRow(CHART_HEIGHT, label_width); + if (ImGui::InputInt("##chart_height", &chart_height_, 10, 10)) chart_height_ = std::clamp(chart_height_, 100, 500); + + checkBox("Enable live stream logging", &log_livestream_); + ImGui::BeginDisabled(!log_livestream_); + ImGui::SetNextItemWidth(-90.0f); + inputText("##log_path", &log_path_, "", ImGuiInputTextFlags_ReadOnly); + ImGui::SameLine(); + if (ImGui::Button("Browse...")) { + FileDialog::getExistingDirectory("Log File Location", utils::homePath(), [this](const std::string &fn) { + if (!fn.empty()) log_path_ = fn; + }); + } + ImGui::EndDisabled(); + + ImGui::Separator(); + bool accepted = false, done = false; + dialogButtons("OK", &accepted, &done); + if (accepted) { + save(); + done = true; + } + FileDialog::draw(); // nested so the directory picker stacks on this modal + if (done) { + open_ = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void SettingsDialog::save() { + if (std::exchange(settings.theme, theme_) != settings.theme) applyTheme(settings.theme); + settings.max_cached_minutes = cached_minutes_; + settings.chart_height = chart_height_; + settings.log_livestream = log_livestream_; + settings.log_path = log_path_; + settings.drag_direction = (Settings::DragDirection)drag_direction_; + settings.changed(); +} diff --git a/openpilot/tools/cabana/ui/dialogs/settingsdialog.h b/openpilot/tools/cabana/ui/dialogs/settingsdialog.h new file mode 100644 index 0000000000..c9947a91a1 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/settingsdialog.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +#include "tools/cabana/core/settings.h" +#include "tools/cabana/ui/util.h" + +class SettingsDialog { +public: + void open(); + void draw(); + +private: + void save(); + + bool open_ = false; + PopupOwner popup_; + int theme_ = 0; + int cached_minutes_ = 0; + int drag_direction_ = 0; + int chart_height_ = 0; + bool log_livestream_ = false; + std::string log_path_; +}; diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.cc b/openpilot/tools/cabana/ui/dialogs/streamselector.cc new file mode 100644 index 0000000000..44336447c6 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.cc @@ -0,0 +1,322 @@ +#include "tools/cabana/ui/dialogs/streamselector.h" + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/streams/devicestream.h" +#include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +void OpenReplayWidget::draw() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Route"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-250.0f); + inputText("##route", &route_, "Enter route name or browse for local/remote route"); + ImGui::SameLine(); + if (ImGui::Button("Remote route...")) { + routes_dialog_.open(utils::guarded(alive_, [this](bool accepted, const std::string &route) { + if (accepted) route_ = route; + })); + } + ImGui::SameLine(); + if (ImGui::Button("Local route...")) { + FileDialog::getExistingDirectory("Open Local Route", settings.last_route_dir, utils::guarded(alive_, [this](const std::string &dir) { + if (!dir.empty()) { + route_ = dir; + settings.last_route_dir = std::filesystem::absolute(dir).parent_path().string(); + } + })); + } + checkBox("Road camera", &cameras_[0]); + ImGui::SameLine(); + checkBox("Driver camera", &cameras_[1]); + ImGui::SameLine(); + checkBox("Wide road camera", &cameras_[2]); +} + +void OpenReplayWidget::drawPopups() { + routes_dialog_.draw(); +} + +std::unique_ptr OpenReplayWidget::open() { + std::string route = route_; + std::string data_dir; + if (auto idx = route.rfind('/'); idx != std::string::npos && util::file_exists(route)) { + data_dir = route.substr(0, idx + 1); + route = route.substr(idx + 1); + } + + bool is_valid_format = Route::parseRoute(route).str.size() > 0; + if (!is_valid_format) { + MessageBox::warning("Warning", "Invalid route format: '" + route + "'"); + } else { + auto replay_stream = std::make_unique(); + Connection err = replay_stream->error.connect([](const std::string &msg) { + MessageBox::warning("Error", msg); + }); + uint32_t flags = REPLAY_FLAG_NONE; + if (cameras_[1]) flags |= REPLAY_FLAG_CABIN_CAMERA; + if (cameras_[2]) flags |= REPLAY_FLAG_WIDE_ROAD; + if (flags == REPLAY_FLAG_NONE && !cameras_[0]) flags = REPLAY_FLAG_NO_VIPC; + + if (replay_stream->loadRoute(route, data_dir, flags)) { + return replay_stream; + } + } + return nullptr; +} + +namespace { +const uint32_t speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U}; +const uint32_t data_speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U, 2000U, 5000U}; +} + +OpenPandaWidget::OpenPandaWidget() { + if (can && dynamic_cast(can) != nullptr) { + already_connected_ = true; + return; + } + refreshSerials(); + buildConfigForm(); +} + +void OpenPandaWidget::refreshSerials() { + serials_ = Panda::list(); + serial_index_ = 0; +} + +void OpenPandaWidget::buildConfigForm() { + std::string serial = serial_index_ < static_cast(serials_.size()) ? serials_[serial_index_] : ""; + has_fd_ = false; + has_panda_ = !serial.empty(); + if (has_panda_) { + try { + Panda panda(serial); + has_fd_ = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2); + } catch (const std::exception &e) { + fprintf(stderr, "failed to open panda %s\n", serial.c_str()); + has_panda_ = false; + } + } + + if (has_panda_) { + config.serial = serial; + config.bus_config.resize(3); + can_speed_index_.assign(3, 0); + data_speed_index_.assign(3, 0); + for (int i = 0; i < 3; i++) { + for (int j = 0; j < static_cast(std::size(speeds)); j++) { + if (speeds[j] == config.bus_config[i].can_speed_kbps) can_speed_index_[i] = j; + } + for (int j = 0; j < static_cast(std::size(data_speeds)); j++) { + if (data_speeds[j] == config.bus_config[i].data_speed_kbps) data_speed_index_[i] = j; + } + } + } else { + config.serial = ""; + } +} + +void OpenPandaWidget::draw() { + if (already_connected_) { + ImGui::Text("Already connected to %s.", can->routeName().c_str()); + ImGui::TextUnformatted("Close the current connection via [File menu -> Close Stream] before connecting to another Panda."); + return; + } + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Serial"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-100.0f); + if (comboBox("##serial", &serial_index_, serials_)) buildConfigForm(); + ImGui::SameLine(); + if (ImGui::Button("Refresh")) { + refreshSerials(); + buildConfigForm(); + } + + if (!has_panda_) { + ImGui::TextUnformatted("No panda found"); + return; + } + for (int i = 0; i < static_cast(config.bus_config.size()); i++) { + ImGui::PushID(i); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Bus %d:", i); + ImGui::SameLine(); + ImGui::TextUnformatted("CAN Speed (kbps):"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (comboBox("##can_speed", &can_speed_index_[i], speeds, (int)std::size(speeds))) { + config.bus_config[i].can_speed_kbps = speeds[can_speed_index_[i]]; + } + if (has_fd_) { + ImGui::SameLine(); + checkBox("CAN-FD", &config.bus_config[i].can_fd); + ImGui::SameLine(); + ImGui::TextUnformatted("Data Speed (kbps):"); + ImGui::SameLine(); + ImGui::BeginDisabled(!config.bus_config[i].can_fd); + ImGui::SetNextItemWidth(90.0f); + if (comboBox("##data_speed", &data_speed_index_[i], data_speeds, (int)std::size(data_speeds))) { + config.bus_config[i].data_speed_kbps = data_speeds[data_speed_index_[i]]; + } + ImGui::EndDisabled(); + } + ImGui::PopID(); + } +} + +std::unique_ptr OpenPandaWidget::open() { + try { + return std::make_unique(config); + } catch (std::exception &e) { + MessageBox::warning("Warning", std::string("Failed to connect to panda: '") + e.what() + "'"); + return nullptr; + } +} + +void OpenDeviceWidget::draw() { + ImGui::RadioButton("MSGQ", &mode_, 0); + ImGui::RadioButton("ZMQ", &mode_, 1); + // the radio buttons are the label column, the ip address is the field column + const float label_width = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemInnerSpacing.x + + std::max(ImGui::CalcTextSize("MSGQ").x, ImGui::CalcTextSize("ZMQ").x) + + ImGui::GetStyle().ItemInnerSpacing.x; + ImGui::SameLine(label_width); + ImGui::BeginDisabled(mode_ != 1); + ImGui::SetNextItemWidth(-1.0f); + validatedText("##ip", &ip_address_, validateIpAddress, "Enter device Ip Address", ipValidator); + ImGui::EndDisabled(); +} + +std::unique_ptr OpenDeviceWidget::open() { + std::string ip = ip_address_.empty() ? "127.0.0.1" : ip_address_; + bool msgq = mode_ == 0; + return std::make_unique(msgq ? "" : ip); +} + +#ifdef __linux__ + +OpenSocketCanWidget::OpenSocketCanWidget() { + refreshDevices(); +} + +void OpenSocketCanWidget::refreshDevices() { + devices_.clear(); + // 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) { + devices_.push_back(entry.path().filename().string()); + } + } + device_index_ = 0; + config.device = devices_.empty() ? "" : devices_[0]; +} + +void OpenSocketCanWidget::draw() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Device"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(300.0f); + if (comboBox("##device", &device_index_, devices_)) config.device = devices_[device_index_]; + ImGui::SameLine(); + if (ImGui::Button("Refresh", ImVec2(100.0f, 0.0f))) refreshDevices(); +} + +std::unique_ptr OpenSocketCanWidget::open() { + try { + return std::make_unique(config); + } catch (std::exception &e) { + MessageBox::warning("Warning", std::string("Failed to connect to SocketCAN device: '") + e.what() + "'"); + return nullptr; + } +} +#endif + +void StreamSelector::open(Callback on_done) { + on_done_ = std::move(on_done); + open_ = true; + popup_.reset(); + first_frame_ = true; + dbc_file_.clear(); + widgets_.clear(); + widgets_.push_back(std::make_unique()); + widgets_.push_back(std::make_unique()); +#ifdef __linux__ + if (SocketCanStream::available()) { + widgets_.push_back(std::make_unique()); + } +#endif + widgets_.push_back(std::make_unique()); +} + +void StreamSelector::draw() { + if (!open_) return; + if (!beginDialog("Open stream", &popup_, ImVec2(640.0f, 0.0f))) return; + + AbstractOpenStreamWidget *current = nullptr; + if (ImGui::BeginTabBar("streams")) { + for (auto &w : widgets_) { + // a fresh dialog every time, so the first tab is always the current one + ImGuiTabItemFlags tab_flags = (first_frame_ && w == widgets_.front()) ? ImGuiTabItemFlags_SetSelected : 0; + if (ImGui::BeginTabItem(w->title(), nullptr, tab_flags)) { + current = w.get(); + ImGui::BeginChild("tab", ImVec2(0, 130.0f)); + w->draw(); + ImGui::EndChild(); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } + first_frame_ = false; + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("dbc File"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-90.0f); + inputText("##dbc", &dbc_file_, "Choose a dbc file to open", ImGuiInputTextFlags_ReadOnly); + ImGui::SameLine(); + if (ImGui::Button("Browse...")) { + FileDialog::getOpenFileName("Open File", settings.last_dir, ".dbc", [this](const std::string &fn) { + if (!fn.empty()) { + dbc_file_ = fn; + settings.last_dir = std::filesystem::absolute(fn).parent_path().string(); + } + }); + } + ImGui::Separator(); + + bool accepted = false, rejected = false; + std::unique_ptr stream; + bool open_clicked = false; + dialogButtons("Open", &open_clicked, &rejected, current != nullptr && current->openEnabled()); + if (open_clicked) { + if (stream = current->open(); stream) accepted = true; + } + + // nested so they stack on this modal + if (current) current->drawPopups(); + FileDialog::draw(); + MessageBox::draw(); + + if (accepted || rejected) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (accepted || rejected) { + open_ = false; + widgets_.clear(); + auto on_done = std::move(on_done_); + if (on_done) on_done(std::move(stream), dbc_file_); + } +} diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.h b/openpilot/tools/cabana/ui/dialogs/streamselector.h new file mode 100644 index 0000000000..b57e602cab --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/streams/pandastream.h" +#ifdef __linux__ +#include "tools/cabana/streams/socketcanstream.h" +#endif +#include "tools/cabana/ui/dialogs/routesdialog.h" +#include "tools/cabana/ui/util.h" + +class AbstractOpenStreamWidget { +public: + virtual ~AbstractOpenStreamWidget() = default; + virtual const char *title() const = 0; + virtual void draw() = 0; + // nested dialogs, drawn at the modal's level: a popup opened from inside the tab's child window is + // never the top-most modal's own call site, so PopupOwner would skip it + virtual void drawPopups() {} + virtual std::unique_ptr open() = 0; + virtual bool openEnabled() const { return true; } +}; + +class OpenReplayWidget : public AbstractOpenStreamWidget { +public: + const char *title() const override { return "Replay"; } + void draw() override; + void drawPopups() override; + std::unique_ptr open() override; + +private: + std::string route_; + bool cameras_[3] = {true, false, false}; + RoutesDialog routes_dialog_; + // guards dialog continuations that outlive the stream selector + std::shared_ptr alive_ = std::make_shared(true); +}; + +class OpenPandaWidget : public AbstractOpenStreamWidget { +public: + OpenPandaWidget(); + const char *title() const override { return "Panda"; } + void draw() override; + std::unique_ptr open() override; + bool openEnabled() const override { return !already_connected_; } + +private: + void refreshSerials(); + void buildConfigForm(); + + bool already_connected_ = false; + std::vector serials_; + int serial_index_ = 0; + bool has_panda_ = false; + bool has_fd_ = false; + std::vector can_speed_index_, data_speed_index_; + PandaStreamConfig config = {}; +}; + +class OpenDeviceWidget : public AbstractOpenStreamWidget { +public: + const char *title() const override { return "Device"; } + void draw() override; + std::unique_ptr open() override; + +private: + int mode_ = 1; // 0 = MSGQ, 1 = ZMQ + std::string ip_address_; +}; + +#ifdef __linux__ +class OpenSocketCanWidget : public AbstractOpenStreamWidget { +public: + OpenSocketCanWidget(); + const char *title() const override { return "SocketCAN"; } + void draw() override; + std::unique_ptr open() override; + +private: + void refreshDevices(); + + std::vector devices_; + int device_index_ = 0; + SocketCanStreamConfig config = {}; +}; +#endif + +class StreamSelector { +public: + using Callback = std::function stream, const std::string &dbc_file)>; + // on_done gets a null stream on cancel + void open(Callback on_done); + void draw(); + +private: + bool open_ = false; + PopupOwner popup_; + bool first_frame_ = false; + std::string dbc_file_; + std::vector> widgets_; + Callback on_done_; +}; diff --git a/openpilot/tools/cabana/ui/helpoverlay.cc b/openpilot/tools/cabana/ui/helpoverlay.cc new file mode 100644 index 0000000000..5ae3dab6f6 --- /dev/null +++ b/openpilot/tools/cabana/ui/helpoverlay.cc @@ -0,0 +1,196 @@ +#include "tools/cabana/ui/helpoverlay.h" + +#include +#include +#include +#include +#include + +#include "tools/cabana/ui/util.h" + +namespace { +struct HelpRun { + std::string text; + bool bold = false; + bool chip = false; // background-color:lightGray + bool swatch = false; // a colored square + ImU32 color = 0; // 0 = default text color +}; + +ImU32 helpColor(const std::string &name) { + if (name == "gray") return IM_COL32(128, 128, 128, 255); + if (name == "blue") return IM_COL32(0, 0, 255, 255); + if (name == "red") return IM_COL32(255, 0, 0, 255); + unsigned rgb = 0; + if (name.size() == 7 && name[0] == '#' && sscanf(name.c_str() + 1, "%6x", &rgb) == 1) { + return IM_COL32((rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, 255); + } + return 0; +} + +std::vector> parseHelpHtml(const std::string &raw) { + std::vector> lines(1); + HelpRun style; + std::vector span_stack; + bool prev_space = true; // html collapses whitespace; leading whitespace is dropped + std::string pending; + auto flush = [&]() { + if (!pending.empty()) { + HelpRun run = style; + run.text = pending; + lines.back().push_back(run); + pending.clear(); + } + }; + auto push_swatch = [&](ImU32 color) { + flush(); + HelpRun run = style; + run.swatch = true; + if (color) run.color = color; + lines.back().push_back(run); + prev_space = false; + }; + for (size_t i = 0; i < raw.size(); ++i) { + const char c = raw[i]; + if (c == '<') { + const size_t close = raw.find('>', i); + if (close == std::string::npos) break; + const std::string tag = raw.substr(i + 1, close - i - 1); + i = close; + if (tag.compare(0, 3, "!--") == 0) continue; + flush(); + if (tag == "b") { + style.bold = true; + } else if (tag == "/b") { + style.bold = false; + } else if (tag.compare(0, 2, "br") == 0) { + lines.emplace_back(); + prev_space = true; + } else if (tag.compare(0, 4, "span") == 0) { + span_stack.push_back(style); + const size_t st = tag.find("style=\""); + if (st != std::string::npos) { + const std::string css = tag.substr(st + 7, tag.find('"', st + 7) - st - 7); + size_t pos = 0; + while (pos < css.size()) { + const size_t semi = css.find(';', pos); + const std::string decl = css.substr(pos, semi == std::string::npos ? std::string::npos : semi - pos); + const size_t colon = decl.find(':'); + if (colon != std::string::npos) { + const std::string key = decl.substr(0, colon), value = decl.substr(colon + 1); + if (key == "color") style.color = helpColor(value); + if (key == "background-color") style.chip = true; + } + if (semi == std::string::npos) break; + pos = semi + 1; + } + } + } else if (tag == "/span") { + if (!span_stack.empty()) { + style = span_stack.back(); + span_stack.pop_back(); + } + } + } else if (c == '&') { + static const std::pair entities[] = {{" ", " "}}; + bool matched = false; + for (const auto &[name, text] : entities) { + if (raw.compare(i, strlen(name), name) == 0) { + pending += text; + prev_space = false; + i += strlen(name) - 1; + matched = true; + break; + } + } + if (!matched && raw.compare(i, 7, "■") == 0) { // the filled square of the byte color legend + push_swatch(0); + i += 6; + matched = true; + } + if (!matched) { + pending += c; + prev_space = false; + } + } else if (isspace(static_cast(c))) { + if (!prev_space) pending += ' '; + prev_space = true; + } else if (c == '#' && i + 6 < raw.size() && helpColor(raw.substr(i, 7)) != 0) { // #rrggbb legend token + push_swatch(helpColor(raw.substr(i, 7))); + i += 6; + } else { + pending += c; + prev_space = false; + } + } + flush(); + for (auto &line : lines) { // trim the collapsed whitespace at the line ends + if (!line.empty() && !line.back().text.empty() && line.back().text.back() == ' ') line.back().text.pop_back(); + if (!line.empty() && !line.front().text.empty() && line.front().text.front() == ' ') line.front().text.erase(0, 1); + } + while (!lines.empty() && lines.back().empty()) lines.pop_back(); + return lines; +} +} // namespace + +void HelpOverlay::toggle() { + visible_ = !visible_; + opened_frame_ = ImGui::GetFrameCount(); +} + +void HelpOverlay::add(const std::string &text, const ImRect &rect) { + if (visible_) texts_.emplace_back(text, rect); +} + +void HelpOverlay::draw() { + if (!visible_) return; + const ImGuiViewport *viewport = ImGui::GetMainViewport(); + ImDrawList *dl = ImGui::GetForegroundDrawList(); + const ImRect work_rect(viewport->WorkPos, ImVec2(viewport->WorkPos.x + viewport->WorkSize.x, viewport->WorkPos.y + viewport->WorkSize.y)); + dl->AddRectFilled(viewport->Pos, ImVec2(viewport->Pos.x + viewport->Size.x, viewport->Pos.y + viewport->Size.y), IM_COL32(0, 0, 0, 50)); + ImFont *font = ImGui::GetFont(); + ImFont *bold_font = boldFont() ? boldFont() : font; + const float font_size = ImGui::GetFontSize(); + const float line_h = ImGui::GetTextLineHeightWithSpacing(); + auto run_width = [&](const HelpRun &r) { + if (r.swatch) return font_size; + return (r.bold ? bold_font : font)->CalcTextSizeA(font_size, FLT_MAX, 0.0f, r.text.c_str()).x; + }; + for (const auto &[raw, rect] : texts_) { + if (raw.empty()) continue; + const auto lines = parseHelpHtml(raw); + float width = 0; + for (const auto &line : lines) { + float w = 0; + for (const auto &r : line) w += run_width(r); + width = std::max(width, w); + } + const ImVec2 size(width, lines.size() * line_h); + const ImVec2 center((rect.Min.x + rect.Max.x) * 0.5f, (rect.Min.y + rect.Max.y) * 0.5f); + if (!work_rect.Contains(center)) continue; // a torn off panel is in another viewport + const ImVec2 min(center.x - size.x * 0.5f - 8.0f, center.y - size.y * 0.5f - 8.0f); + const ImVec2 max(center.x + size.x * 0.5f + 8.0f, center.y + size.y * 0.5f + 8.0f); + // pale yellow in the light theme + const ImU32 tooltip_base = isDarkTheme() ? ImGui::GetColorU32(ImGuiCol_PopupBg) : IM_COL32(255, 255, 220, 255); + dl->AddRectFilled(min, max, tooltip_base); + float y = min.y + 8.0f; + for (const auto &line : lines) { + float x = min.x + 8.0f; + for (const auto &r : line) { + const float w = run_width(r); + const ImU32 color = r.color ? r.color : ImGui::GetColorU32(ImGuiCol_Text); + if (r.swatch) { + dl->AddRectFilled(ImVec2(x + 2, y + 3), ImVec2(x + font_size - 2, y + font_size - 1), color); + } else { + if (r.chip) dl->AddRectFilled(ImVec2(x, y), ImVec2(x + w, y + font_size), IM_COL32(211, 211, 211, 255)); // lightGray + dl->AddText(r.bold ? bold_font : font, font_size, ImVec2(x, y), color, r.text.c_str()); + } + x += w; + } + y += line_h; + } + } + texts_.clear(); + // ignore the release of the click that opened the overlay + if (ImGui::IsMouseReleased(ImGuiMouseButton_Left) && ImGui::GetFrameCount() != opened_frame_) visible_ = false; +} diff --git a/openpilot/tools/cabana/ui/helpoverlay.h b/openpilot/tools/cabana/ui/helpoverlay.h new file mode 100644 index 0000000000..2639d20946 --- /dev/null +++ b/openpilot/tools/cabana/ui/helpoverlay.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +#include "imgui_internal.h" + +// Dims the window and shows each widget's whatsThis text at its center; any click closes it. +// The texts are rich text: ,
, , &entities; and #rrggbb +// tokens (the video legend) are rendered, everything else is ignored. +class HelpOverlay { +public: + void toggle(); + bool visible() const { return visible_; } + // collected while the widgets draw, consumed by draw() + void add(const std::string &text, const ImRect &rect); + void draw(); + +private: + std::vector> texts_; + bool visible_ = false; + int opened_frame_ = -1; +}; diff --git a/openpilot/tools/cabana/ui/icons.h b/openpilot/tools/cabana/ui/icons.h new file mode 100644 index 0000000000..3a736fb3c9 --- /dev/null +++ b/openpilot/tools/cabana/ui/icons.h @@ -0,0 +1,40 @@ +#pragma once + +// bootstrap icon glyphs, merged into the fonts by style.cc +namespace icon { +constexpr const char ARROW_CLOCKWISE[] = "\xef\x84\x96"; +constexpr const char ARROW_COUNTERCLOCKWISE[] = "\xef\x84\x97"; +constexpr const char ARROW_DOWN_LEFT_SQUARE[] = "\xef\x84\x9d"; +constexpr const char ARROW_UP_RIGHT_SQUARE[] = "\xef\x85\x83"; +constexpr const char CHEVRON_LEFT[] = "\xef\x8a\x84"; +constexpr const char CHEVRON_RIGHT[] = "\xef\x8a\x85"; +constexpr const char DASH[] = "\xef\x8b\xaa"; +constexpr const char DASH_SQUARE[] = "\xef\x8b\xa9"; +constexpr const char EXCLAMATION_TRIANGLE[] = "\xef\x8c\xbb"; +constexpr const char FAST_FORWARD[] = "\xef\x9f\xb4"; +constexpr const char FILETYPE_CSV[] = "\xef\x9d\x83"; +constexpr const char FOLDER[] = "\xef\x8f\x99"; +constexpr const char FILE_EARMARK[] = "\xef\x8e\x92"; +constexpr const char FILE_EARMARK_RULED[] = "\xef\x8e\x85"; +constexpr const char PLUS_SQUARE[] = "\xef\x93\xbd"; +constexpr const char GRAPH_UP[] = "\xef\x8f\xb2"; +constexpr const char GRIP_HORIZONTAL[] = "\xef\x8f\xbd"; +constexpr const char INFO_CIRCLE[] = "\xef\x90\xb1"; +constexpr const char LIST[] = "\xef\x91\xb9"; +constexpr const char PAUSE[] = "\xef\x93\x84"; +constexpr const char PENCIL[] = "\xef\x93\x8b"; +constexpr const char PLAY[] = "\xef\x93\xb5"; +constexpr const char PLUS[] = "\xef\x93\xbe"; +constexpr const char RAQUO[] = "\xc2\xbb"; // U+00BB, not a bootstrap icon: the toolbar extension button +constexpr const char REPEAT[] = "\xef\xa0\x93"; +constexpr const char REPEAT_1[] = "\xef\xa0\x92"; +constexpr const char REWIND[] = "\xef\xa0\x99"; +constexpr const char SKIP_END[] = "\xef\x95\x98"; +constexpr const char STOPWATCH[] = "\xef\x96\x97"; +constexpr const char THREE_DOTS[] = "\xef\x97\x94"; +constexpr const char WINDOW_STACK[] = "\xef\x9b\x92"; +constexpr const char X[] = "\xef\x98\xaa"; +constexpr const char X_LG[] = "\xef\x99\x99"; +constexpr const char X_SQUARE[] = "\xef\x98\xa9"; +constexpr const char ZOOM_OUT[] = "\xef\x98\xad"; +} // namespace icon diff --git a/openpilot/tools/cabana/ui/inistate.cc b/openpilot/tools/cabana/ui/inistate.cc new file mode 100644 index 0000000000..2243690600 --- /dev/null +++ b/openpilot/tools/cabana/ui/inistate.cc @@ -0,0 +1,138 @@ +#include "tools/cabana/ui/inistate.h" + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/qtstate.h" +#include "tools/cabana/ui/util.h" + +namespace inistate { + +MainWindowState main_window; + +namespace { + +void *readOpen(ImGuiContext *, ImGuiSettingsHandler *, const char *name) { + return strcmp(name, "MainWindow") == 0 ? (void *)&main_window : nullptr; +} + +void readLine(ImGuiContext *, ImGuiSettingsHandler *, void *entry, const char *line) { + auto *state = (MainWindowState *)entry; + int x = 0, y = 0, flag = 0; + float ratio = 0.0f; + if (sscanf(line, "Pos=%d,%d", &x, &y) == 2) { + state->pos[0] = x; + state->pos[1] = y; + } else if (sscanf(line, "Size=%d,%d", &x, &y) == 2) { + state->size[0] = x; + state->size[1] = y; + state->has_geometry = true; + } else if (sscanf(line, "Maximized=%d", &flag) == 1) { + state->maximized = flag != 0; + } else if (sscanf(line, "VideoSplitterRatio=%f", &ratio) == 1) { + state->video_splitter_ratio = ratio; + } else if (sscanf(line, "MessagesVisible=%d", &flag) == 1) { + state->messages_visible = flag != 0; + } else if (sscanf(line, "VideoVisible=%d", &flag) == 1) { + state->video_visible = flag != 0; + } +} + +void writeAll(ImGuiContext *, ImGuiSettingsHandler *handler, ImGuiTextBuffer *buf) { + buf->appendf("[%s][MainWindow]\n", handler->TypeName); + if (main_window.has_geometry) { + buf->appendf("Pos=%d,%d\n", main_window.pos[0], main_window.pos[1]); + buf->appendf("Size=%d,%d\n", main_window.size[0], main_window.size[1]); + } + buf->appendf("Maximized=%d\n", main_window.maximized ? 1 : 0); + buf->appendf("VideoSplitterRatio=%.4f\n", main_window.video_splitter_ratio); + buf->appendf("MessagesVisible=%d\n", main_window.messages_visible ? 1 : 0); + buf->appendf("VideoVisible=%d\n", main_window.video_visible ? 1 : 0); + buf->append("\n"); +} + +std::string migrateQtHeaderState(const qtstate::QtHeaderState &header) { + // imgui restarts the hash at "###" in a window name and BeginTable seeds the table id from the window id + const ImGuiID table_id = ImHashStr("messages", 0, ImHashStr(MESSAGES_PANEL_ID, 0)); + const float cell_padding = ImGui::GetStyle().CellPadding.x; + + ImGuiTextBuffer buf; + buf.appendf("[Table][0x%08X,%d]\n", table_id, qtstate::kMessageColumnCount); + for (int i = 0; i < qtstate::kMessageColumnCount; ++i) { + buf.appendf("Column %-2d", i); + if (i == 6) { + buf.append(" Weight=1.0000"); // DATA is the stretch column + } else { + buf.appendf(" Width=%d", std::max(1, (int)(header.width[i] - 2 * cell_padding))); + } + buf.appendf(" Visible=%d Order=%d", header.hidden[i] ? 0 : 1, header.visual[i]); + if (header.sort_shown && i == header.sort_section) { + // the port feeds imgui the flipped direction so the arrow matches Qt (flipSortDirection + // in ui/widgets/messageswidget.cc): Qt ascending is imgui descending + buf.appendf(" Sort=0%c", header.sort_order == 0 ? '^' : 'v'); + } + buf.append("\n"); + } + buf.append("\n"); + return std::string(buf.c_str()); +} + +std::string migrateQtState() { + auto geometry = qtstate::parseQtGeometry(settings.geometry); + auto splitter = qtstate::parseQtSplitter(settings.video_splitter_state); + + ImGuiTextBuffer buf; + if (geometry || splitter) { + buf.append("[Cabana][MainWindow]\n"); + if (geometry) { + buf.appendf("Pos=%d,%d\n", geometry->x, geometry->y); + buf.appendf("Size=%d,%d\n", geometry->w, geometry->h); + buf.appendf("Maximized=%d\n", geometry->maximized ? 1 : 0); + } + if (splitter) buf.appendf("VideoSplitterRatio=%.4f\n", splitter->ratio); + buf.append("\n"); + } + if (auto header = qtstate::parseQtHeaderState(settings.message_header_state)) { + buf.append(migrateQtHeaderState(*header).c_str()); + } + return std::string(buf.c_str()); +} + +} // namespace + +void addSettingsHandler() { + ImGuiSettingsHandler handler; + handler.TypeName = "Cabana"; + handler.TypeHash = ImHashStr("Cabana"); + handler.ReadOpenFn = readOpen; + handler.ReadLineFn = readLine; + handler.WriteAllFn = writeAll; + ImGui::AddSettingsHandler(&handler); +} + +void load() { + if (settings.ui_state.empty()) settings.ui_state = migrateQtState(); + if (!settings.ui_state.empty()) + ImGui::LoadIniSettingsFromMemory(settings.ui_state.data(), settings.ui_state.size()); +} + +void applyWindowGeometry(GLFWwindow *window) { + // Qt restoreGeometry corrects off-screen geometry, here we rely on the window manager + if (main_window.has_geometry && main_window.size[0] > 0 && main_window.size[1] > 0) { + glfwSetWindowPos(window, main_window.pos[0], main_window.pos[1]); + glfwSetWindowSize(window, main_window.size[0], main_window.size[1]); + } + if (main_window.maximized) glfwMaximizeWindow(window); +} + +std::string save() { + return std::string(ImGui::SaveIniSettingsToMemory()); +} + +} // namespace inistate diff --git a/openpilot/tools/cabana/ui/inistate.h b/openpilot/tools/cabana/ui/inistate.h new file mode 100644 index 0000000000..a460802d24 --- /dev/null +++ b/openpilot/tools/cabana/ui/inistate.h @@ -0,0 +1,27 @@ +#pragma once +#include + +struct GLFWwindow; + +// The imgui frontend's persisted state: the imgui ini text (windows, dock layout, table +// state) plus a custom [Cabana][MainWindow] section, stored as one string in Settings. +namespace inistate { + +struct MainWindowState { + int pos[2] = {0, 0}; + int size[2] = {0, 0}; + bool maximized = false; + bool has_geometry = false; + float video_splitter_ratio = -1.0f; // < 0: video at its size hint + bool messages_visible = true; + bool video_visible = true; +}; + +extern MainWindowState main_window; + +void addSettingsHandler(); // register the [Cabana] ini section +void load(); // migrate Qt state if needed, then LoadIniSettingsFromMemory +void applyWindowGeometry(GLFWwindow *window); // glfw pos/size/maximize from main_window +std::string save(); // SaveIniSettingsToMemory (caller fills main_window first) + +} // namespace inistate diff --git a/openpilot/tools/cabana/ui/main.cc b/openpilot/tools/cabana/ui/main.cc new file mode 100644 index 0000000000..e111c4d9f9 --- /dev/null +++ b/openpilot/tools/cabana/ui/main.cc @@ -0,0 +1,202 @@ +#include +#include +#include +#include +#include +#include + +#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 +#include "tools/cabana/ui/app.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/utils/util.h" + +#ifdef __GLIBC__ +#include +#endif + +namespace { + +struct CabanaArgs { + bool demo = false; + bool auto_source = false; + bool qcam = false; + bool wide_road = false; + bool cabin = false; + bool msgq = false; + bool panda = false; + bool no_vipc = false; + bool no_cache = false; + std::string panda_serial; + std::string socketcan; + std::string zmq; + 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 connect.comma.ai\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" + " --wide-road load wide road camera (alias: --ecam)\n" + " --cabin load cabin camera (alias: --dcam)\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 read can messages from zmq at the specified ip-address\n" + " --data_dir local directory with routes\n" + " --no-vipc do not output video\n" + " --no-cache turn off the local route file cache\n" + " --dbc dbc file to open\n", + argv0); +} + +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; +} + +// the process exit code, or nullopt to continue +std::optional parseArgs(int argc, char *argv[], CabanaArgs &args) { + 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, "--wide-road") == 0 || std::strcmp(a, "--ecam") == 0) { + args.wide_road = true; + } else if (std::strcmp(a, "--cabin") == 0 || std::strcmp(a, "--dcam") == 0) { + args.cabin = 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; +#ifndef __linux__ + 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, "--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, "--no-cache") == 0) { + args.no_cache = 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; + } + } + return std::nullopt; +} + +} // namespace + +int main(int argc, char *argv[]) { +#ifdef __GLIBC__ + // Worker threads (sparklines, chart series, replay) would each get their own glibc malloc arena and the + // arenas fragment without bound (RSS grew ~3 MB/min with charts open). macOS has a single allocator zone. + mallopt(M_ARENA_MAX, 1); +#endif + // ensure the current dir matches the executable's directory + std::error_code ec; + std::filesystem::current_path(executableDir(), ec); + + CabanaArgs args; + if (auto code = parseArgs(argc, argv, args)) return *code; + + std::unique_ptr stream; + StreamLoader stream_loader; + + if (args.msgq) { + stream = std::make_unique(); + } else if (!args.zmq.empty()) { + stream = std::make_unique(args.zmq); + } else if (args.panda) { + try { + stream = std::make_unique(PandaStreamConfig{.serial = args.panda_serial}); + } catch (std::exception &e) { + fprintf(stderr, "%s\n", e.what()); + return 1; + } +#ifdef __linux__ + } else if (!args.socketcan.empty()) { + if (!SocketCanStream::available()) { + fprintf(stderr, "error: SocketCAN is not available on this system\n"); + return 1; + } + stream = std::make_unique(SocketCanStreamConfig{.device = args.socketcan}); +#endif + } else { + uint32_t replay_flags = REPLAY_FLAG_NONE; + if (args.wide_road) replay_flags |= REPLAY_FLAG_WIDE_ROAD; + if (args.qcam) replay_flags |= REPLAY_FLAG_QCAMERA; + if (args.cabin) replay_flags |= REPLAY_FLAG_CABIN_CAMERA; + if (args.no_vipc) replay_flags |= REPLAY_FLAG_NO_VIPC; + if (args.no_cache) replay_flags |= REPLAY_FLAG_NO_FILE_CACHE; + + std::string route; + if (!args.route.empty()) { + route = args.route; + } else if (args.demo) { + route = DEMO_ROUTE; + } + if (!route.empty()) { + // the route file listing hits the comma API; load behind the window instead of before it + stream_loader = [route, data_dir = args.data_dir, replay_flags, auto_source = args.auto_source]() -> std::unique_ptr { + auto replay_stream = std::make_unique(); + Connection err = replay_stream->error.connect([](const std::string &msg) { + fprintf(stderr, "%s\n", msg.c_str()); + utils::runOnMainThread([msg]() { MessageBox::warning("Error", msg); }); + }); + if (!replay_stream->loadRoute(route, data_dir, replay_flags, auto_source)) { + return nullptr; + } + return replay_stream; + }; + } + } + + return run(std::move(stream), std::move(stream_loader), args.dbc); +} diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc new file mode 100644 index 0000000000..15b6ff8df8 --- /dev/null +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -0,0 +1,929 @@ +#include "tools/cabana/ui/mainwin.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include + +#include "json11/json11.hpp" +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/app.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/inistate.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/tools/findsignal.h" +#include "tools/cabana/ui/tools/findsimilarbits.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/export.h" +#include "tools/cabana/utils/util.h" +#include "tools/replay/py_downloader.h" +#include "tools/replay/util.h" + +namespace { +// dock window ids (the visible titles change, the part after ### is the identity) +constexpr const char *VIDEO_PANEL = "###VideoPanel"; +constexpr const char *CENTER_PANEL = "###CenterWidget"; +constexpr const char *CHARTS_WINDOW = "Charts###ChartsWindow"; +} // namespace + +MainWindow::MainWindow(GLFWwindow *window, std::unique_ptr stream, StreamLoader stream_loader, + const std::string &dbc_file) : window_(window) { + can = &dummy_; + video_splitter_ratio_ = inistate::main_window.video_splitter_ratio; + messages_visible_ = inistate::main_window.messages_visible; + video_visible_ = inistate::main_window.video_visible; + loadFingerprints(); + 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") { + opendbc_names_.push_back(entry.path().filename().string()); + } + } + std::sort(opendbc_names_.begin(), opendbc_names_.end()); + + // download handlers are called from download threads + installDownloadProgressHandler([this](uint64_t cur, uint64_t total, bool success) { + utils::runOnMainThread([this, cur, total, success]() { updateDownloadProgress(cur, total, success); }); + }); + installMessageHandler([this](ReplyMsgType type, const std::string &msg) { + utils::runOnMainThread([this, msg]() { showStatusMessage(msg, 2000); }); + }); + + connections_.push_back(dbc()->fileChanged.connect([this]() { dbcFileChanged(); })); + connections_.push_back(UndoStack::instance()->cleanChanged.connect([this](bool clean) { + window_modified_ = !clean; + updateWindowTitle(); + })); + + startup_stream_ = std::move(stream); + startup_loader_ = std::move(stream_loader); + nextFrame([this, dbc_file]() { + if (startup_loader_) { + loadStartupStream(dbc_file); + } else { + startup_stream_ ? openStream(std::move(startup_stream_), dbc_file) : selectAndOpenStream(); + } + }); +} + +void MainWindow::loadFingerprints() { + std::ifstream json_file((executableDir() / "dbc/car_fingerprint_to_dbc.json")); + 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; + for (const auto &kv : doc.object_items()) { + if (kv.second.is_string()) { + fingerprint_to_dbc_.emplace(kv.first, kv.second.string_value()); + } + } +} + +void MainWindow::drawFileMenu() { + const bool has_stream = hasStream(); + if (ImGui::MenuItem("Open Stream...")) selectAndOpenStream(); + if (ImGui::MenuItem("Close stream", nullptr, false, has_stream)) closeStream(); + if (ImGui::MenuItem("Export to CSV...", nullptr, false, has_stream)) exportToCSV(); + ImGui::Separator(); + + if (ImGui::MenuItem("New DBC File", "Ctrl+N")) newFile(); + if (ImGui::MenuItem("Open DBC File...", "Ctrl+O")) openFile(); + + if (ImGui::BeginMenu("Manage DBC Files", has_stream)) { + drawManageDBCsMenu(); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Open Recent")) { + drawRecentFilesMenu(); + ImGui::EndMenu(); + } + + ImGui::Separator(); + if (ImGui::BeginMenu("Load DBC from commaai/opendbc")) { + for (const auto &name : opendbc_names_) { + if (ImGui::MenuItem(name.c_str())) loadDBCFromOpendbc(name); + } + ImGui::EndMenu(); + } + if (ImGui::MenuItem("Load DBC From Clipboard")) loadFromClipboard(); + + ImGui::Separator(); + const int cnt = dbc()->nonEmptyDBCCount(); + const std::string save_text = cnt > 1 ? "Save " + std::to_string(cnt) + " DBCs..." : "Save DBC..."; + if (ImGui::MenuItem(save_text.c_str(), "Ctrl+S", false, cnt > 0)) save(); + if (ImGui::MenuItem("Save DBC As...", "Ctrl+Shift+S", false, cnt == 1)) saveAs(); + // TODO: Support clipboard for multiple files + if (ImGui::MenuItem("Copy DBC To Clipboard", nullptr, false, cnt == 1)) saveToClipboard(); + + ImGui::Separator(); + if (ImGui::MenuItem("Settings...")) openSettings(); + + ImGui::Separator(); + if (ImGui::MenuItem("Exit", "Ctrl+Q")) close(); +} + +void MainWindow::drawMenuBar() { + if (!ImGui::BeginMainMenuBar()) return; + if (ImGui::BeginMenu("File")) { + drawFileMenu(); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Edit")) { + auto stack = UndoStack::instance(); + const std::string undo_text = stack->canUndo() ? "Undo " + stack->undoText() : "Undo"; + const std::string redo_text = stack->canRedo() ? "Redo " + stack->redoText() : "Redo"; + if (ImGui::MenuItem(undo_text.c_str(), "Ctrl+Z", false, stack->canUndo())) stack->undo(); + if (ImGui::MenuItem(redo_text.c_str(), "Ctrl+Shift+Z", false, stack->canRedo())) stack->redo(); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("View")) { + if (ImGui::MenuItem("Full Screen", "Ctrl+F11")) toggleFullScreen(); + ImGui::Separator(); + ImGui::MenuItem(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); + ImGui::MenuItem(video_dock_title_.empty() ? "##video_dock" : video_dock_title_.c_str(), nullptr, &video_visible_); + ImGui::Separator(); + if (ImGui::MenuItem("Reset Window Layout")) { + messages_visible_ = video_visible_ = true; + reset_layout_ = true; + } + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Tools", hasStream())) { + if (ImGui::MenuItem("Find Similar Bits")) findSimilarBits(); + if (ImGui::MenuItem("Find Signal")) findSignal(); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Help")) { + if (ImGui::MenuItem("Help", "F1")) toggleHelp(); + ImGui::EndMenu(); + } + ImGui::EndMainMenuBar(); +} + +void MainWindow::createDockWidgets() { + widget_connections_.clear(); + messages_widget_ = std::make_unique(); + widget_connections_.push_back(messages_widget_->msgSelectionChanged.connect([this](const MessageId &id) { center_widget_.setMessage(id); })); + + charts_widget_ = std::make_unique(); + center_widget_.setChartsWidget(charts_widget_.get()); + video_widget_ = std::make_unique(); + widget_connections_.push_back(charts_widget_->toggleChartsDocking.connect([this]() { toggleChartsDocking(); })); + widget_connections_.push_back(charts_widget_->showTip.connect([this](double sec) { video_widget_->showThumbnail(sec); })); +} + +void MainWindow::showStatusMessage(const std::string &msg, int timeout_ms) { + status_bar_.message = msg; + status_bar_.message_until = timeout_ms > 0 ? ImGui::GetTime() + timeout_ms / 1000.0 : 0; +} + +void MainWindow::updateWindowTitle() { + std::string title; + for (auto f : dbc()->allDBCFiles()) { + if (!title.empty()) title += " | "; + title += "(" + toString(dbc()->sources(f)) + ") " + f->name(); + } + if (window_modified_) title += "*"; + if (!title.empty()) title += " \xe2\x80\x94 "; // em dash separator + title += "Cabana"; + glfwSetWindowTitle(window_, title.c_str()); +} + +void MainWindow::dbcFileChanged() { + UndoStack::instance()->clear(); + updateWindowTitle(); + nextFrame([this]() { restoreSessionState(); }); +} + +void MainWindow::selectAndOpenStream() { + stream_selector_.open([this](std::unique_ptr stream, const std::string &dbc_file) { + if (stream) { + openStream(std::move(stream), dbc_file); + } else if (!stream_) { + openStream(std::make_unique()); + } + }); +} + +// the route file listing hits the comma API, so the loader runs on a worker behind the window +void MainWindow::loadStartupStream(const std::string &dbc_file) { + wait_dlg_.text = "Loading route..."; + wait_dlg_.value = 0; + wait_dlg_.open = true; + wait_dlg_.show_at = ImGui::GetTime() + 4.0; // minimum duration before the dialog shows + ThreadPool::instance().run([this, dbc_file, loader = std::move(startup_loader_)]() { + AbstractStream *loaded = nullptr; + std::string error; + try { + loaded = loader().release(); + } catch (const std::exception &e) { + // the pool swallows exceptions, so the wait dialog would spin forever + error = e.what(); + } + utils::runOnMainThread([this, dbc_file, loaded, error]() { + wait_dlg_.open = false; + std::unique_ptr stream(loaded); + if (!error.empty()) { + fprintf(stderr, "%s\n", error.c_str()); + MessageBox::warning("Failed to load route", error); + } + stream ? openStream(std::move(stream), dbc_file) : openStream(std::make_unique()); + }); + }); +} + +void MainWindow::closeStream() { + openStream(std::make_unique()); + if (dbc()->nonEmptyDBCCount() > 0) { + dbc()->fileChanged(); + } + showStatusMessage("stream closed"); +} + +void MainWindow::exportToCSV() { + std::string dir = settings.last_dir + "/" + can->routeName() + ".csv"; + FileDialog::getSaveFileName("Export stream to CSV file", dir, ".csv", [](const std::string &fn) { + if (!fn.empty()) { + utils::exportToCSV(fn); + } + }); +} + +void MainWindow::newFile(SourceSet s) { + closeFile(s, [s]() { dbc()->open(s, std::string(""), std::string("")); }); +} + +void MainWindow::openFile(SourceSet s) { + remindSaveChanges([this, s]() { + FileDialog::getOpenFileName("Open File", settings.last_dir, ".dbc", [this, s](const std::string &fn) { + if (!fn.empty()) { + loadFile(fn, s); + } + }); + }); +} + +void MainWindow::loadFile(const std::string &fn, SourceSet s, std::function then) { + if (!fn.empty()) { + closeFile(s, [this, fn, s, then]() { + std::string error; + if (dbc()->open(s, fn, &error)) { + updateRecentFiles(fn); + showStatusMessage("DBC File " + fn + " loaded", 2000); + if (then) then(); + } else { + MessageBox::warning("Failed to load DBC file", "Failed to parse DBC file " + fn, error, then); + } + }); + } else if (then) { + then(); + } +} + +void MainWindow::loadDBCFromOpendbc(const std::string &name) { + loadFile(std::string(OPENDBC_FILE_PATH) + "/" + name); +} + +void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { + std::string text; + if (!utils::getClipboardText(&text)) { + MessageBox::warning("Load From Clipboard", "No clipboard tool found. Install xclip (X11) or wl-clipboard (Wayland)."); + return; + } + if (text.empty()) { + MessageBox::warning("Load From Clipboard", "Clipboard is empty."); + return; + } + + closeFile(s, [s, text]() { + std::string error; + bool ret = dbc()->open(s, std::string(""), text, &error); + if (ret && dbc()->nonEmptyDBCCount() > 0) { + MessageBox::information("Load From Clipboard", "DBC Successfully Loaded!"); + } else { + MessageBox::warning("Failed to load DBC from clipboard", "Make sure that you paste the text with correct format.", error); + } + }); +} + +MainWindow::~MainWindow() { + installDownloadProgressHandler(nullptr); + installMessageHandler(nullptr); + releaseStream(); + can = nullptr; +} + +// the tool dialogs are connected into the messages widget, and the video widget's RouteInfoDlg keeps a raw +// pointer to the replay, so the dialogs go first and the widgets before the stream; the stream's destructor +// joins the threads that read the global `can` +void MainWindow::releaseStream() { + tool_dialogs_.clear(); + wait_dlg_.connection.disconnect(); + wait_dlg_.open = false; + widget_connections_.clear(); + charts_widget_.reset(); + video_widget_.reset(); + center_widget_.clear(); + messages_widget_.reset(); + stream_connections_.clear(); + stream_.reset(); + can = &dummy_; +} + +void MainWindow::openStream(std::unique_ptr stream, const std::string &dbc_file) { + releaseStream(); + startStream(std::move(stream), dbc_file); +} + +void MainWindow::startStream(std::unique_ptr stream, const std::string &dbc_file) { + stream_ = std::move(stream); + can = stream_.get(); + stream_connections_.push_back(can->error.connect([](const std::string &msg) { + MessageBox::warning("Error", msg); + })); + can->start(); + + loadFile(dbc_file, SOURCE_ALL, [this]() { + showStatusMessage("Stream [" + can->routeName() + "] started", 2000); + createDockWidgets(); + + video_dock_title_ = can->routeName(); + // Don't overwrite already loaded DBC + if (!dbc()->nonEmptyDBCCount()) { + newFile(); + } + + stream_connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &) { eventsMerged(); })); + + if (hasStream()) { + wait_dlg_.text = can->liveStreaming() ? "Waiting for the live stream to start..." : "Loading segment data..."; + wait_dlg_.value = 0; + wait_dlg_.open = true; + wait_dlg_.show_at = ImGui::GetTime() + 4.0; // minimum duration before the dialog shows + wait_dlg_.connection = can->eventsMerged.connect([this](const MessageEventsMap &) { + wait_dlg_.open = false; + wait_dlg_.connection.disconnect(); + }); + } + }); +} + +void MainWindow::eventsMerged() { + const std::string fingerprint = can->carFingerprint(); + if (!can->liveStreaming() && std::exchange(car_fingerprint_, fingerprint) != fingerprint) { + video_dock_title_ = "ROUTE: " + can->routeName() + " FINGERPRINT: " + (car_fingerprint_.empty() ? "Unknown Car" : car_fingerprint_); + // Don't overwrite already loaded DBC + auto it = fingerprint_to_dbc_.find(car_fingerprint_); + if (!dbc()->nonEmptyDBCCount() && it != fingerprint_to_dbc_.end()) { + nextFrame([this, dbc_name = it->second]() { loadDBCFromOpendbc(dbc_name + ".dbc"); }); + } + } +} + +void MainWindow::saveFiles(bool as, std::function then) { + const std::vector files = dbc()->nonEmptyDBCFiles(); + auto next = std::make_shared>(); + *next = [this, as, files, next, then](size_t i) { + if (i >= files.size()) { + if (then) then(); + return; + } + auto cb = [next, i]() { (*next)(i + 1); }; + as ? saveFileAs(files[i], cb) : saveFile(files[i], cb); + }; + (*next)(0); +} + +void MainWindow::save(std::function then) { + saveFiles(false, std::move(then)); +} + +void MainWindow::saveAs(std::function then) { + saveFiles(true, std::move(then)); +} + +void MainWindow::closeFile(SourceSet s, std::function then) { + remindSaveChanges([s, then]() { + if (s == SOURCE_ALL) { + dbc()->closeAll(); + } else { + dbc()->close(s); + } + if (then) then(); + }); +} + +void MainWindow::closeFile(DBCFile *dbc_file) { + assert(dbc_file != nullptr); + remindSaveChanges([this, dbc_file]() { + dbc()->close(dbc_file); + // Ensure we always have at least one file open + if (dbc()->dbcCount() == 0) { + newFile(); + } + }); +} + +void MainWindow::saveFile(DBCFile *dbc_file, std::function then) { + assert(dbc_file != nullptr); + if (!dbc_file->filename.empty()) { + dbc_file->save(); + UndoStack::instance()->setClean(); + showStatusMessage("File saved", 2000); + if (then) then(); + } else if (!dbc_file->isEmpty()) { + saveFileAs(dbc_file, then); + } else if (then) { + then(); + } +} + +void MainWindow::saveFileAs(DBCFile *dbc_file, std::function then) { + std::string title = "Save File (bus: " + toString(dbc()->sources(dbc_file)) + ")"; + std::string default_path = (std::filesystem::path(settings.last_dir) / "untitled.dbc").string(); + FileDialog::getSaveFileName(title, default_path, ".dbc", [this, dbc_file, then](const std::string &fn) { + if (!fn.empty()) { + dbc_file->saveAs(fn); + UndoStack::instance()->setClean(); + showStatusMessage("File saved as " + fn, 2000); + updateRecentFiles(fn); + } + if (then) then(); + }); +} + +void MainWindow::saveToClipboard() { + // Should not be called with more than 1 file open + for (auto dbc_file : dbc()->nonEmptyDBCFiles()) { + saveFileToClipboard(dbc_file); + } +} + +void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { + assert(dbc_file != nullptr); + copyToClipboard(dbc_file->generateDBC()); +} + +void MainWindow::copyToClipboard(const std::string &text) { + if (utils::setClipboardText(text)) { + MessageBox::information("Copy To Clipboard", "DBC Successfully copied!"); + } else { + MessageBox::warning("Copy To Clipboard", "Failed to copy DBC to clipboard. Install xclip (X11) or wl-clipboard (Wayland)."); + } +} + +void MainWindow::drawManageDBCsMenu() { + for (int source : can->sources) { + if (source >= 64) continue; // Sent and blocked buses are handled implicitly + + SourceSet ss = {source, uint8_t(source + 128), uint8_t(source + 192)}; + + auto dbc_file = dbc()->findDBCFile(source); + const std::string title = "Bus " + std::to_string(source) + " (" + (dbc_file ? dbc_file->name() : "No DBCs loaded") + ")"; + ImGui::PushID(source); + if (ImGui::BeginMenu(title.c_str())) { + if (ImGui::MenuItem("New DBC File...")) newFile(ss); + if (ImGui::MenuItem("Open DBC File...")) openFile(ss); + if (ImGui::MenuItem("Load DBC From Clipboard...")) loadFromClipboard(ss, false); + + // Show sub-menu for each dbc for this source. + if (dbc_file) { + ImGui::Separator(); + ImGui::MenuItem((dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")").c_str(), nullptr, false, false); + if (ImGui::MenuItem("Save...")) saveFile(dbc_file); + if (ImGui::MenuItem("Save As...")) saveFileAs(dbc_file); + if (ImGui::MenuItem("Copy to Clipboard...")) saveFileToClipboard(dbc_file); + if (ImGui::MenuItem("Remove from this bus...")) closeFile(ss, {}); + if (ImGui::MenuItem("Remove from all buses...")) closeFile(dbc_file); + } + ImGui::EndMenu(); + } + ImGui::PopID(); + } +} + +void MainWindow::updateRecentFiles(const std::string &fn) { + settings.recent_files.erase(std::remove(settings.recent_files.begin(), settings.recent_files.end(), fn), settings.recent_files.end()); + settings.recent_files.insert(settings.recent_files.begin(), fn); + while (settings.recent_files.size() > MAX_RECENT_FILES) { + settings.recent_files.pop_back(); + } + settings.last_dir = std::filesystem::absolute(fn).parent_path().string(); +} + +void MainWindow::drawRecentFilesMenu() { + int num_recent_files = std::min(settings.recent_files.size(), MAX_RECENT_FILES); + if (!num_recent_files) { + ImGui::MenuItem("No Recent Files", nullptr, false, false); + return; + } + + for (int i = 0; i < num_recent_files; ++i) { + std::string text = std::to_string(i + 1) + " " + std::filesystem::path(settings.recent_files[i]).filename().string(); + ImGui::PushID(i); + if (ImGui::MenuItem(text.c_str())) loadFile(settings.recent_files[i]); + ImGui::PopID(); + } +} + +void MainWindow::remindSaveChanges(std::function then) { + if (UndoStack::instance()->isClean()) { + UndoStack::instance()->clear(); + if (then) then(); + return; + } + std::string text = "You have unsaved changes. Press ok to save them, cancel to discard."; + MessageBox::question("Unsaved Changes", text, [this, then](bool ok) { + if (ok) { + save([this, then]() { remindSaveChanges(then); }); + } else { + UndoStack::instance()->clear(); + if (then) then(); + } + }); +} + +void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool success) { + const double fraction = total > 0 ? cur / (double)total : 0.0; + if (wait_dlg_.open) wait_dlg_.value = (int)(fraction * 100); + if (success && cur < total) { + status_bar_.progress_value = fraction; + status_bar_.progress_text = "Downloading " + std::to_string((int)(fraction * 100)) + "% (" + formattedDataSize(total) + ")"; + status_bar_.progress_visible = true; + } else { + status_bar_.progress_visible = false; + } +} + +void MainWindow::toggleChartsDocking() { + charts_floating_ = !charts_floating_; + charts_widget_->setIsDocked(!charts_floating_); +} + +void MainWindow::close() { + if (closing_) return; + closing_ = true; + remindSaveChanges([this]() { finishClose(); }); +} + +void MainWindow::finishClose() { + // save states + auto &state = inistate::main_window; + state.maximized = glfwGetWindowAttrib(window_, GLFW_MAXIMIZED); + if (full_screen_) { +#ifndef __APPLE__ + // macOS full screen is the native Cocoa toggle, keep the loaded geometry there + state.pos[0] = windowed_rect_[0]; state.pos[1] = windowed_rect_[1]; + state.size[0] = windowed_rect_[2]; state.size[1] = windowed_rect_[3]; +#endif + } else if (!state.maximized) { + glfwGetWindowPos(window_, &state.pos[0], &state.pos[1]); + glfwGetWindowSize(window_, &state.size[0], &state.size[1]); + } + state.has_geometry = state.size[0] > 0 && state.size[1] > 0; + state.video_splitter_ratio = video_splitter_ratio_; + state.messages_visible = messages_visible_; + state.video_visible = video_visible_; + settings.ui_state = inistate::save(); + + saveSessionState(); + settings.save(); + exited_ = true; +} + +void MainWindow::openSettings() { + settings_dialog_.open(); +} + +void MainWindow::findSimilarBits() { + auto dlg = std::make_unique(); + dlg->connections_.push_back(dlg->openMessage.connect([this](const MessageId &id) { messages_widget_->selectMessage(id); })); + tool_dialogs_.push_back(std::move(dlg)); +} + +void MainWindow::findSignal() { + auto dlg = std::make_unique(); + dlg->connections_.push_back(dlg->openMessage.connect([this](const MessageId &id) { messages_widget_->selectMessage(id); })); + tool_dialogs_.push_back(std::move(dlg)); +} + +void MainWindow::toggleHelp() { + help_overlay_.toggle(); +} + +void MainWindow::toggleFullScreen() { +#ifdef __APPLE__ + toggleNativeFullScreen(window_); +#else + full_screen_ = !full_screen_; + if (full_screen_) { + glfwGetWindowPos(window_, &windowed_rect_[0], &windowed_rect_[1]); + glfwGetWindowSize(window_, &windowed_rect_[2], &windowed_rect_[3]); + GLFWmonitor *monitor = glfwGetPrimaryMonitor(); + const GLFWvidmode *mode = glfwGetVideoMode(monitor); + glfwSetWindowMonitor(window_, monitor, 0, 0, mode->width, mode->height, mode->refreshRate); + } else { + glfwSetWindowMonitor(window_, nullptr, windowed_rect_[0], windowed_rect_[1], windowed_rect_[2], windowed_rect_[3], 0); + glfwMaximizeWindow(window_); + } +#endif +} + +void MainWindow::saveSessionState() { + settings.recent_dbc_file = ""; + settings.active_msg_id = ""; + settings.selected_msg_ids.clear(); + settings.active_charts.clear(); + + const auto files = dbc()->nonEmptyDBCFiles(); + if (!files.empty()) settings.recent_dbc_file = files.front()->filename; + + if (auto *detail = center_widget_.getDetailWidget()) { + auto [active_id, ids] = detail->serializeMessageIds(); + settings.active_msg_id = active_id; + settings.selected_msg_ids = ids; + } + if (charts_widget_) { + settings.active_charts = charts_widget_->serializeChartIds(); + } +} + +void MainWindow::restoreSessionState() { + if (settings.recent_dbc_file.empty() || dbc()->nonEmptyDBCCount() == 0) return; + + if (dbc()->nonEmptyDBCFiles().front()->filename != settings.recent_dbc_file) return; + + if (!settings.selected_msg_ids.empty()) { + center_widget_.ensureDetailWidget()->restoreTabs(settings.active_msg_id, settings.selected_msg_ids); + } + + if (charts_widget_ != nullptr && !settings.active_charts.empty()) { + charts_widget_->restoreChartsFromIds(settings.active_charts); + } +} + +void MainWindow::handleShortcuts() { + const ImGuiIO &io = ImGui::GetIO(); + for (const KeyEvent &e : takeKeyEvents()) { + const bool ctrl = e.mods & (GLFW_MOD_CONTROL | GLFW_MOD_SUPER); + const bool shift = e.mods & GLFW_MOD_SHIFT; + // a focused text input consumes Space but not the Ctrl/F-key sequences + if (e.key == GLFW_KEY_SPACE && !ctrl && can && !io.WantTextInput) can->pause(!can->isPaused()); + if (e.key == GLFW_KEY_F1) toggleHelp(); + if (e.key == GLFW_KEY_F11 && ctrl) toggleFullScreen(); + // an open popup or a focused text input takes Esc first + if (e.key == GLFW_KEY_ESCAPE && full_screen_ && !io.WantTextInput && + !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) { + toggleFullScreen(); + } + if (!ctrl) continue; + if (e.key == GLFW_KEY_N) newFile(); + if (e.key == GLFW_KEY_O) openFile(); + if (e.key == GLFW_KEY_S) { + if (shift) { + if (dbc()->nonEmptyDBCCount() == 1) saveAs(); + } else if (dbc()->nonEmptyDBCCount() > 0) { + save(); + } + } + // a focused text input swallows Ctrl+Z / Ctrl+Shift+Z + if (e.key == GLFW_KEY_Z && !io.WantTextInput) shift ? UndoStack::instance()->redo() : UndoStack::instance()->undo(); + if (e.key == GLFW_KEY_Q) close(); + } +} + +void MainWindow::drawStatusBar() { + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::GetStyle().Colors[ImGuiCol_MenuBarBg]); + ImGui::BeginChild("status_bar", ImVec2(0, ImGui::GetFrameHeight()), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar); + // a borderless child gets no WindowPadding, so both ends sit flush against the edge and clip. Inset by + // WindowPadding.x, which lines the text up with the content of the docked panels above (the messages table). + const float width = ImGui::GetContentRegionAvail().x; + const float pad = ImGui::GetStyle().WindowPadding.x; + ImGui::SetCursorPosX(pad); + ImGui::AlignTextToFramePadding(); + // a temporary message hides the normal widgets, permanent widgets stay on the right + auto &bar = status_bar_; + if (!bar.message.empty() && (bar.message_until == 0 || ImGui::GetTime() < bar.message_until)) { + ImGui::TextUnformatted(bar.message.c_str()); + } else { + bar.message.clear(); + ImGui::TextUnformatted("For Help, Press F1"); + } + if (bar.progress_visible) { + ImGui::SameLine(width - pad - 300.0f); + ImGui::ProgressBar(bar.progress_value, ImVec2(300.0f, 16.0f), bar.progress_text.c_str()); + } + ImGui::EndChild(); + ImGui::PopStyleColor(); +} + +void MainWindow::drawWaitDialog() { + const char *id = "###WaitDialog"; + if (wait_dlg_.open && !ImGui::IsPopupOpen(id) && ImGui::GetTime() >= wait_dlg_.show_at) ImGui::OpenPopup(id); + if (!ImGui::IsPopupOpen(id)) return; // keep submitting until CloseCurrentPopup ran, a stale modal blocks all input + ImGui::SetNextWindowSize(ImVec2(400.0f, 0.0f), ImGuiCond_Always); + setNextDialogWindow(ImVec2(0.0f, 0.0f)); + if (ImGui::BeginPopupModal(id, nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextUnformatted(wait_dlg_.text.c_str()); + // no text until the progress is set + ImGui::ProgressBar(wait_dlg_.value / 100.0f, ImVec2(-1.0f, 0.0f), wait_dlg_.value == 0 ? "" : (const char *)nullptr); + bool abort = false, rejected = false; + dialogButtons("Abort", &abort, &rejected, true, nullptr); + if (abort || rejected) { + wait_dlg_.open = false; + close(); + } + if (!wait_dlg_.open) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } +} + +void MainWindow::drawDockspace() { + const ImGuiViewport *viewport = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(viewport->WorkPos); + ImGui::SetNextWindowSize(viewport->WorkSize); + ImGui::SetNextWindowViewport(viewport->ID); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus | + ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoBackground | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; + ImGui::Begin("##host", nullptr, flags); + ImGui::PopStyleVar(3); + + // the status bar sits below the dockspace: reserve its height plus the item spacing between the two, + // otherwise the host window is a few pixels taller than the viewport and scrolls + const float status_height = full_screen_ ? 0.0f : ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y; + const ImVec2 dock_size(ImGui::GetContentRegionAvail().x, ImGui::GetContentRegionAvail().y - status_height); + const ImGuiID dock_id = ImGui::GetID("cabana_dockspace"); + if (reset_layout_ || ImGui::DockBuilderGetNode(dock_id) == nullptr) { + // messages left, video (with charts) right, center widget in the middle + ImGui::DockBuilderRemoveNode(dock_id); + ImGui::DockBuilderAddNode(dock_id, ImGuiDockNodeFlags_DockSpace); + ImGui::DockBuilderSetNodeSize(dock_id, dock_size); + ImGuiID center = dock_id, left = 0, right = 0; + ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.28f, &left, ¢er); + ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.4f, &right, ¢er); + ImGui::DockBuilderDockWindow(MESSAGES_PANEL_ID, left); + ImGui::DockBuilderDockWindow(VIDEO_PANEL, right); + ImGui::DockBuilderDockWindow(CENTER_PANEL, center); + ImGui::DockBuilderGetNode(center)->LocalFlags |= ImGuiDockNodeFlags_NoTabBar; + ImGui::DockBuilderFinish(dock_id); + reset_layout_ = false; + } + // a panel never shrinks past the width where the signal view's tool bar squishes + const float min_panel_width = SignalView::minimumWidth() + (ImGui::GetStyle().WindowPadding.x + ImGui::GetStyle().WindowBorderSize) * 2; + ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(min_panel_width, ImGui::GetStyle().WindowMinSize.y)); + ImGui::DockSpace(dock_id, dock_size); + ImGui::PopStyleVar(); + if (!full_screen_) drawStatusBar(); + ImGui::End(); +} + +namespace { +// closing a panel that floated out into its own os window brings it back into the default layout, only +// the close button of a docked panel hides it +bool floatingOut() { return ImGui::GetWindowViewport() != ImGui::GetMainViewport(); } + +// the side panels float out like the dialogs, and their dock nodes have no window menu button: its +// only entry hides the tab bar, and with it the title and the close button +void setNextPanelClass() { + ImGuiWindowClass window_class; + window_class.ViewportFlagsOverrideSet = ImGuiViewportFlags_NoAutoMerge; + window_class.DockNodeFlagsOverrideSet = ImGuiDockNodeFlags_NoWindowMenuButton; + ImGui::SetNextWindowClass(&window_class); +} +} // namespace + +void MainWindow::drawMessagesPanel() { + const std::string name = messages_widget_->title() + MESSAGES_PANEL_ID; + setNextPanelClass(); + if (ImGui::Begin(name.c_str(), &messages_visible_)) { + help_overlay_.add(messages_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + messages_widget_->draw(); + } + const bool floating = floatingOut(); + ImGui::End(); + if (!messages_visible_ && floating) messages_visible_ = reset_layout_ = true; +} + +void MainWindow::drawVideoPanel() { + const std::string name = video_dock_title_ + VIDEO_PANEL; + setNextPanelClass(); + const bool video_open = ImGui::Begin(name.c_str(), &video_visible_); + const bool floating = floatingOut(); + if (!video_open) { + video_widget_->setVisible(false); // the dock is collapsed or tabbed behind another one, like hideEvent + } else { + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const bool live = can->liveStreaming(); + // the bordered child pads its content, so the heights the widget asks for grow by the padding + const float video_padding = ImGui::GetStyle().WindowPadding.y * 2.0f; + const float default_h = video_widget_->defaultHeight(avail.x) + video_padding; + const float video_hint = video_splitter_ratio_ >= 0.0f ? avail.y * video_splitter_ratio_ : default_h; + float video_h = charts_floating_ ? avail.y : std::clamp(video_hint, 0.0f, avail.y - 1.0f); + if (live) video_h = default_h; // display video at minimum size. + // dragging below half of the minimum size collapses the video, it never shrinks below it otherwise + if (!charts_floating_ && !live) { + const float min_h = std::min(video_widget_->sizeHintHeight() + video_padding, avail.y - 1.0f); + video_h = video_h < min_h / 2 ? 0.0f : std::max(video_h, min_h); + } + if (video_h > 0.0f) { + ImGui::BeginChild("video", ImVec2(0, video_h), ImGuiChildFlags_Borders); + help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + video_widget_->draw(); + ImGui::EndChild(); + } else { + video_widget_->setVisible(false); // the splitter collapsed the video: stop the vipc thread + } + if (!charts_floating_) { + // the gap between the video and the charts is the same as the padding at the sides + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); + ImGui::InvisibleButton("##splitter", ImVec2(-1.0f, ImGui::GetStyle().WindowPadding.x)); + if (ImGui::IsItemActive() && !live) { + // the size of the video is the position of the handle inside the splitter + const float top = ImGui::GetWindowPos().y + ImGui::GetCursorStartPos().y; + video_splitter_ratio_ = std::clamp((ImGui::GetMousePos().y - top) / avail.y, 0.0f, 1.0f); + } + if (ImGui::IsItemHovered() && !live) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); + // the chart list scrolls in its own child, the container itself never scrolls + ImGui::BeginChild("charts", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PopStyleVar(); + help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + charts_widget_->draw(); + ImGui::EndChild(); + } + } + ImGui::End(); + if (!video_visible_ && floating) video_visible_ = reset_layout_ = true; +} + +void MainWindow::draw() { +#ifdef __APPLE__ + full_screen_ = isNativeFullScreen(window_); +#endif + auto pending = std::move(next_frame_); + next_frame_.clear(); + for (auto &fn : pending) fn(); + + if (ImGui::GetTopMostPopupModal() == nullptr) { + handleShortcuts(); + } else { + takeKeyEvents(); // modal dialogs swallow the shortcuts + } + if (!full_screen_) drawMenuBar(); + drawDockspace(); + + // the central widget has no scrollbars of its own (the views inside scroll) + if (ImGui::Begin(CENTER_PANEL, nullptr, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + center_widget_.draw(); + if (auto *detail = center_widget_.getDetailWidget(); detail && help_overlay_.visible()) { + for (const auto &[text, rect] : detail->helpRects()) help_overlay_.add(text, rect); + } + } + ImGui::End(); + if (messages_widget_ && messages_visible_) drawMessagesPanel(); + if (video_widget_ && !video_visible_) video_widget_->setVisible(false); + if (video_widget_ && video_visible_) drawVideoPanel(); + if (charts_widget_ && charts_floating_) { + bool open = true; + ImGui::SetNextWindowSize(ImGui::GetMainViewport()->WorkSize, ImGuiCond_Appearing); + setNextWindowFloatsOut(); + if (ImGui::Begin(CHARTS_WINDOW, &open, ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) charts_widget_->draw(); + ImGui::End(); + if (!open) toggleChartsDocking(); + } + for (auto it = tool_dialogs_.begin(); it != tool_dialogs_.end();) { + it = (*it)->draw() ? it + 1 : tool_dialogs_.erase(it); + } + + stream_selector_.draw(); + settings_dialog_.draw(); + drawWaitDialog(); + FileDialog::draw(); + MessageBox::draw(); + help_overlay_.draw(); + + // Escape closes the top-most non-modal popup (a menu or a combo list) on its own; the modal dialogs + // handled Escape themselves above when they were on top + if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + ImGuiWindow *top = topPopupWindow(); + if (top != nullptr && !(top->Flags & ImGuiWindowFlags_Modal)) ImGui::ClosePopupToLevel(GImGui->OpenPopupStack.Size - 1, true); + } +} diff --git a/openpilot/tools/cabana/ui/mainwin.h b/openpilot/tools/cabana/ui/mainwin.h new file mode 100644 index 0000000000..72c24a3464 --- /dev/null +++ b/openpilot/tools/cabana/ui/mainwin.h @@ -0,0 +1,138 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/ui/app.h" +#include "tools/cabana/ui/dialogs/settingsdialog.h" +#include "tools/cabana/ui/dialogs/streamselector.h" +#include "tools/cabana/ui/helpoverlay.h" +#include "tools/cabana/ui/tools/tooldialog.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/widgets/detailwidget.h" +#include "tools/cabana/ui/widgets/messageswidget.h" +#include "tools/cabana/ui/widgets/videowidget.h" + +struct GLFWwindow; + +class MainWindow { +public: + MainWindow(GLFWwindow *window, std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file); + ~MainWindow(); + void draw(); + void toggleChartsDocking(); + void close(); // remind unsaved changes, save state, exit + bool exited() const { return exited_; } + void showStatusMessage(const std::string &msg, int timeout_ms = 0); + void loadFile(const std::string &fn, SourceSet s = SOURCE_ALL, std::function then = {}); + + void selectAndOpenStream(); + void openStream(std::unique_ptr stream, const std::string &dbc_file = {}); + void closeStream(); + void exportToCSV(); + + void newFile(SourceSet s = SOURCE_ALL); + void openFile(SourceSet s = SOURCE_ALL); + void loadDBCFromOpendbc(const std::string &name); + void save(std::function then = {}); + void saveAs(std::function then = {}); + void saveToClipboard(); + +private: + bool hasStream() const { return dynamic_cast(can) == nullptr; } + void releaseStream(); + void startStream(std::unique_ptr stream, const std::string &dbc_file); + void loadStartupStream(const std::string &dbc_file); + void remindSaveChanges(std::function then); + void closeFile(SourceSet s, std::function then); + void closeFile(DBCFile *dbc_file); + void saveFiles(bool as, std::function then); + void saveFile(DBCFile *dbc_file, std::function then = {}); + void saveFileAs(DBCFile *dbc_file, std::function then = {}); + void saveFileToClipboard(DBCFile *dbc_file); + void copyToClipboard(const std::string &text); + void loadFingerprints(); + void loadFromClipboard(SourceSet s = SOURCE_ALL, bool close_all = true); + void updateRecentFiles(const std::string &fn); + void dbcFileChanged(); + void updateDownloadProgress(uint64_t cur, uint64_t total, bool success); + void openSettings(); + void findSimilarBits(); + void findSignal(); + void toggleHelp(); + void toggleFullScreen(); + void updateWindowTitle(); + void eventsMerged(); + void saveSessionState(); + void restoreSessionState(); + void finishClose(); + void nextFrame(std::function fn) { next_frame_.push_back(std::move(fn)); } + void createDockWidgets(); + + void handleShortcuts(); + void drawMenuBar(); + void drawFileMenu(); + void drawManageDBCsMenu(); + void drawRecentFilesMenu(); + void drawDockspace(); + void drawMessagesPanel(); + void drawVideoPanel(); + void drawStatusBar(); + void drawWaitDialog(); + + GLFWwindow *window_; + std::unique_ptr startup_stream_; // opened on the first frame + StreamLoader startup_loader_; // run on a worker after the first frame + std::unique_ptr stream_; // `can` points here, or at dummy_ when no stream is open + DummyStream dummy_; + std::unique_ptr messages_widget_; + CenterWidget center_widget_; + std::unique_ptr video_widget_; + std::unique_ptr charts_widget_; + StreamSelector stream_selector_; + SettingsDialog settings_dialog_; + HelpOverlay help_overlay_; + std::unordered_map fingerprint_to_dbc_; + std::vector opendbc_names_; + enum { MAX_RECENT_FILES = 15 }; + std::string car_fingerprint_; + std::string video_dock_title_; + bool messages_visible_ = true; + bool video_visible_ = true; + bool reset_layout_ = false; + bool full_screen_ = false; +#ifndef __APPLE__ + int windowed_rect_[4] = {0, 0, 1600, 900}; +#endif + bool charts_floating_ = false; + float video_splitter_ratio_ = -1.0f; // < 0: the video widget is at its size hint + std::vector> tool_dialogs_; + bool closing_ = false; + bool exited_ = false; + bool window_modified_ = false; + struct StatusBar { + std::string message; + double message_until = 0; + bool progress_visible = false; + float progress_value = 0; + std::string progress_text; + } status_bar_; + // "Loading segment data..." dialog + struct WaitDialog { + bool open = false; + double show_at = 0; + std::string text; + int value = 0; + Connection connection; + } wait_dlg_; + std::vector> next_frame_; + Connections connections_; + Connections stream_connections_; + Connections widget_connections_; +}; diff --git a/openpilot/tools/cabana/ui/qtstate.cc b/openpilot/tools/cabana/ui/qtstate.cc new file mode 100644 index 0000000000..f4283420cd --- /dev/null +++ b/openpilot/tools/cabana/ui/qtstate.cc @@ -0,0 +1,171 @@ +#include "tools/cabana/ui/qtstate.h" + +#include +#include + +namespace qtstate { + +namespace { + +// big-endian QDataStream reader; any read past the end latches the failed state +class Cursor { +public: + explicit Cursor(const std::vector &data) : data_(data) {} + + bool ok() const { return ok_; } + size_t remaining() const { return ok_ ? data_.size() - pos_ : 0; } + + uint8_t u8() { return read(1) ? data_[pos_ - 1] : 0; } + uint16_t u16() { + if (!read(2)) return 0; + return (uint16_t(data_[pos_ - 2]) << 8) | data_[pos_ - 1]; + } + uint32_t u32() { + if (!read(4)) return 0; + return (uint32_t(data_[pos_ - 4]) << 24) | (uint32_t(data_[pos_ - 3]) << 16) | + (uint32_t(data_[pos_ - 2]) << 8) | data_[pos_ - 1]; + } + int32_t i32() { return (int32_t)u32(); } + void skip(size_t n) { read(n); } + +private: + bool read(size_t n) { + if (!ok_ || data_.size() - pos_ < n) { + ok_ = false; + return false; + } + pos_ += n; + return true; + } + + const std::vector &data_; + size_t pos_ = 0; + bool ok_ = true; +}; + +} // namespace + +std::optional parseQtGeometry(const std::vector &data) { + Cursor c(data); + if (c.u32() != 0x01D9D0CB) return std::nullopt; + const uint16_t major = c.u16(); + c.u16(); // minor + if (!c.ok() || major > 3) return std::nullopt; + + c.skip(16); // frameGeometry + int x1 = c.i32(), y1 = c.i32(), x2 = c.i32(), y2 = c.i32(); // normalGeometry + c.i32(); // screenNumber + const bool maximized = c.u8() != 0; + c.u8(); // fullScreen + if (major >= 2) c.i32(); // screenWidth + if (major >= 3) { + // the client-area rect actually restored + x1 = c.i32(); y1 = c.i32(); x2 = c.i32(); y2 = c.i32(); + } + if (!c.ok()) return std::nullopt; + return QtGeometry{x1, y1, x2 - x1 + 1, y2 - y1 + 1, maximized}; +} + +std::optional parseQtSplitter(const std::vector &data) { + Cursor c(data); + if (c.i32() != 0xff) return std::nullopt; + const int32_t version = c.i32(); + if (!c.ok() || version > 1) return std::nullopt; + + const uint32_t count = c.u32(); + if (!c.ok() || count != 2) return std::nullopt; + const int32_t first = c.i32(); + const int32_t second = c.i32(); + if (!c.ok() || first + second <= 0) return std::nullopt; + return QtSplitter{first / float(first + second)}; +} + +std::optional parseQtHeaderState(const std::vector &data) { + constexpr int N = kMessageColumnCount; + Cursor c(data); + if (c.i32() != 0xff) return std::nullopt; + if (c.i32() != 0) return std::nullopt; // version + + c.i32(); // orientation + const int32_t sort_order = c.i32(); + const int32_t sort_section = c.i32(); + const bool sort_shown = c.u8() != 0; + if (!c.ok()) return std::nullopt; + + QtHeaderState state{}; + state.sort_section = sort_section; + state.sort_order = sort_order; + state.sort_shown = sort_shown; + for (int i = 0; i < N; ++i) state.visual[i] = i; + + // visualIndices: logical -> visual, empty means identity + const uint32_t visual_count = c.u32(); + if (!c.ok() || visual_count > c.remaining() / 4) return std::nullopt; + if (visual_count > 0) { + std::vector visual(visual_count); + for (uint32_t i = 0; i < visual_count; ++i) visual[i] = c.i32(); + if (!c.ok()) return std::nullopt; + bool valid = visual_count == (uint32_t)N; + if (valid) { + bool seen[N] = {}; + for (int v : visual) { + if (v < 0 || v >= N || seen[v]) { valid = false; break; } + seen[v] = true; + } + } + if (valid) { + for (int i = 0; i < N; ++i) state.visual[i] = visual[i]; + } + } + + // logicalIndices: visual -> logical, unused but must be consumed + const uint32_t logical_count = c.u32(); + if (!c.ok() || logical_count > c.remaining() / 4) return std::nullopt; + c.skip(logical_count * 4); + + // sectionHidden: QBitArray indexed by visual position + const uint32_t hidden_bits = c.u32(); + if (!c.ok() || hidden_bits / 8 > c.remaining()) return std::nullopt; + std::vector hidden_bytes((hidden_bits + 7) / 8); + for (auto &b : hidden_bytes) b = c.u8(); + if (!c.ok()) return std::nullopt; + + // hiddenSectionSize: logical index -> size before hiding + const uint32_t hidden_size_count = c.u32(); + if (!c.ok() || hidden_size_count > c.remaining() / 8) return std::nullopt; + std::map hidden_sizes; + for (uint32_t i = 0; i < hidden_size_count; ++i) { + const int key = c.i32(); + hidden_sizes[key] = c.i32(); + } + if (!c.ok()) return std::nullopt; + + c.i32(); // length + c.i32(); // sectionCount + c.skip(5); // movable, clickable, highlight, stretchLastSection, cascading + c.skip(24); // stretchSections, contentsSections, defaultSectionSize, minimumSectionSize, defaultAlignment, globalResizeMode + if (!c.ok()) return std::nullopt; + + // SectionItems in visual order; a Qt4 item with count != 1 stands for count sections + const uint32_t item_count = c.u32(); + if (!c.ok() || item_count > c.remaining() / 12) return std::nullopt; + std::vector sizes; + for (uint32_t i = 0; i < item_count; ++i) { + const int size = c.i32(); + const int count = c.i32(); + c.i32(); // resizeMode + if (!c.ok() || count <= 0 || (int)sizes.size() + count > N) return std::nullopt; + for (int j = 0; j < count; ++j) sizes.push_back(size / count); + } + if (!c.ok() || (int)sizes.size() != N) return std::nullopt; + + for (int i = 0; i < N; ++i) { + const int v = state.visual[i]; + state.hidden[i] = (uint32_t)v < hidden_bits && (hidden_bytes[v / 8] & (1 << (v % 8))) != 0; + auto it = hidden_sizes.find(i); + state.width[i] = (state.hidden[i] && it != hidden_sizes.end()) ? it->second : sizes[v]; + } + return state; +} + +} // namespace qtstate diff --git a/openpilot/tools/cabana/ui/qtstate.h b/openpilot/tools/cabana/ui/qtstate.h new file mode 100644 index 0000000000..76dbd7400f --- /dev/null +++ b/openpilot/tools/cabana/ui/qtstate.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include + +// Parsers for the Qt frontend's persisted QByteArray blobs (QWidget::saveGeometry, +// QSplitter::saveState, QHeaderView::saveState). Used once to migrate a Qt cabana.json +// to the imgui frontend's ini state. No Qt and no imgui. +// TODO: Delete this migration (qtstate.{h,cc}, migrateQtState in inistate.cc and the Qt +// byte-array fields in Settings) after users have had time to migrate to ui_state. +namespace qtstate { + +constexpr int kMessageColumnCount = 7; + +struct QtGeometry { + int x, y, w, h; + bool maximized; +}; + +struct QtSplitter { + float ratio; +}; + +struct QtHeaderState { + int sort_section; + int sort_order; // 0 = Qt::AscendingOrder, 1 = Qt::DescendingOrder + bool sort_shown; + int visual[kMessageColumnCount]; + int width[kMessageColumnCount]; + bool hidden[kMessageColumnCount]; +}; + +std::optional parseQtGeometry(const std::vector &data); +std::optional parseQtSplitter(const std::vector &data); +std::optional parseQtHeaderState(const std::vector &data); + +} // namespace qtstate diff --git a/openpilot/tools/cabana/ui/style.cc b/openpilot/tools/cabana/ui/style.cc new file mode 100644 index 0000000000..b1b5c6cee6 --- /dev/null +++ b/openpilot/tools/cabana/ui/style.cc @@ -0,0 +1,280 @@ +#include "tools/cabana/ui/app.h" + +#include +#include +#include + +#include "implot.h" +#include "tools/cabana/core/settings.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +namespace fs = std::filesystem; + +namespace { +bool g_dark = false; +ImFont *g_ui_font = nullptr; +ImFont *g_bold_font = nullptr; +ImFont *g_mono_font = nullptr; +ImFont *g_large_font = nullptr; + +void addIconFont(float size, ImFont *base) { + ImFontConfig cfg; + cfg.MergeMode = base != nullptr; + cfg.GlyphMinAdvanceX = size; + if (base != nullptr) { + ImFontBaked *baked = base->GetFontBaked(size); + const float center = baked != nullptr ? (baked->Ascent + baked->Descent) * 0.5f : size * 0.5f; + cfg.GlyphOffset.y = std::round(size * 0.5f - center); + } + static const ImWchar ranges[] = {0xF000, 0xF8FF, 0}; + ImGui::GetIO().Fonts->AddFontFromFileTTF(BOOTSTRAP_ICONS_TTF, size, &cfg, ranges); +} + +ImFont *addFont(const fs::path &path, float size) { + ImFontConfig cfg; + cfg.OversampleH = 2; + cfg.OversampleV = 2; + ImFont *font = ImGui::GetIO().Fonts->AddFontFromFileTTF(path.c_str(), size, &cfg); + if (font != nullptr) addIconFont(size, font); + return font; +} +} // namespace + +void loadFonts() { + ImGuiIO &io = ImGui::GetIO(); + const fs::path fonts = fs::path(CABANA_FONTS_DIR); + g_ui_font = addFont(fonts / "Inter-Regular.ttf", 16.0f); + g_bold_font = addFont(fonts / "Inter-SemiBold.ttf", 16.0f); + g_mono_font = addFont(fonts / "JetBrainsMono-Medium.ttf", 15.0f); + g_large_font = addFont(fonts / "Inter-Bold.ttf", 50.0f); + if (g_ui_font != nullptr) io.FontDefault = g_ui_font; + if (g_bold_font == nullptr) g_bold_font = g_ui_font; + if (g_mono_font == nullptr) g_mono_font = g_ui_font; + if (g_large_font == nullptr) g_large_font = g_bold_font; +} + +void applyTheme(int theme) { + const bool dark = theme == DARK_THEME; + g_dark = dark; + if (dark) { + ImGui::StyleColorsDark(); + ImPlot::StyleColorsDark(); + } else { + ImGui::StyleColorsLight(); + ImPlot::StyleColorsLight(); + } + + ImGuiStyle &style = ImGui::GetStyle(); + style.WindowRounding = 0.0f; + style.ChildRounding = 0.0f; + style.PopupRounding = 0.0f; + style.FrameRounding = 2.0f; + style.GrabRounding = 2.0f; + style.ScrollbarRounding = 2.0f; + style.TabRounding = 2.0f; + style.WindowBorderSize = 1.0f; + style.FrameBorderSize = 1.0f; + style.TabBorderSize = 1.0f; + style.WindowPadding = ImVec2(8.0f, 7.0f); + style.FramePadding = ImVec2(6.0f, 3.0f); + style.ItemSpacing = ImVec2(8.0f, 5.0f); + style.ScrollbarSize = 14.0f; + style.GrabMinSize = 13.0f; + + auto c = [](const CabanaColor &col, float a = 1.0f) { return colorRgb(col.r, col.g, col.b, a); }; + ImVec4 *colors = style.Colors; + if (dark) { + // the low contrast Darcula grays are opened up: text and outlines sit further from the window and + // base grays + const ImVec4 highlight = c(DarkTheme::highlight); + const ImVec4 outline = colorRgb(0x5a, 0x5d, 0x60); + colors[ImGuiCol_WindowBg] = c(DarkTheme::window); + colors[ImGuiCol_ChildBg] = c(DarkTheme::base); + colors[ImGuiCol_PopupBg] = c(DarkTheme::base); + colors[ImGuiCol_MenuBarBg] = c(DarkTheme::window); + colors[ImGuiCol_DockingEmptyBg] = c(DarkTheme::window); + colors[ImGuiCol_Text] = colorRgb(0xdc, 0xdc, 0xdc); + colors[ImGuiCol_TextDisabled] = colorRgb(0x8c, 0x8c, 0x8c); + colors[ImGuiCol_Border] = outline; + colors[ImGuiCol_BorderShadow] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_FrameBg] = colorRgb(0x2e, 0x30, 0x32); // darker than the base so fields read as sunken + colors[ImGuiCol_FrameBgHovered] = colorRgb(0x3a, 0x3d, 0x40); + colors[ImGuiCol_FrameBgActive] = colorRgb(0x45, 0x48, 0x4b); + colors[ImGuiCol_Button] = c(DarkTheme::button); + colors[ImGuiCol_ButtonHovered] = colorRgb(0x52, 0x56, 0x59); + colors[ImGuiCol_ButtonActive] = colorRgb(0x2b, 0x2d, 0x30); + colors[ImGuiCol_Header] = highlight; + colors[ImGuiCol_HeaderHovered] = c(DarkTheme::highlight, 0.8f); + colors[ImGuiCol_HeaderActive] = highlight; + colors[ImGuiCol_CheckMark] = c(DarkTheme::bright_text); + colors[ImGuiCol_SliderGrab] = colorRgb(0x8f, 0x92, 0x95); + colors[ImGuiCol_SliderGrabActive] = colorRgb(0xa8, 0xab, 0xae); + colors[ImGuiCol_ScrollbarBg] = c(DarkTheme::window); + colors[ImGuiCol_ScrollbarGrab] = colorRgb(0x70, 0x73, 0x76); + colors[ImGuiCol_ScrollbarGrabHovered] = colorRgb(0x85, 0x88, 0x8b); + colors[ImGuiCol_ScrollbarGrabActive] = c(DarkTheme::light); + colors[ImGuiCol_Separator] = outline; + colors[ImGuiCol_SeparatorHovered] = c(DarkTheme::highlight, 0.6f); + colors[ImGuiCol_SeparatorActive] = highlight; + colors[ImGuiCol_ResizeGrip] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_ResizeGripHovered] = c(DarkTheme::highlight, 0.6f); + colors[ImGuiCol_ResizeGripActive] = highlight; + colors[ImGuiCol_Tab] = c(DarkTheme::window); + colors[ImGuiCol_TabHovered] = colorRgb(0x4b, 0x4e, 0x52); + colors[ImGuiCol_TabSelected] = c(DarkTheme::base); + colors[ImGuiCol_TabSelectedOverline] = highlight; + colors[ImGuiCol_TabDimmed] = c(DarkTheme::window); + colors[ImGuiCol_TabDimmedSelected] = c(DarkTheme::base); + colors[ImGuiCol_TabDimmedSelectedOverline] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_TitleBg] = c(DarkTheme::window); + colors[ImGuiCol_TitleBgActive] = c(DarkTheme::window); + colors[ImGuiCol_TitleBgCollapsed] = c(DarkTheme::window); + colors[ImGuiCol_TableHeaderBg] = c(DarkTheme::window); + colors[ImGuiCol_TableBorderStrong] = outline; + colors[ImGuiCol_TableBorderLight] = colorRgb(0x23, 0x26, 0x28); // darker than the cells, like the qt grid + colors[ImGuiCol_TableRowBg] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_TableRowBgAlt] = colorRgb(0xff, 0xff, 0xff, 0.06f); + colors[ImGuiCol_TextSelectedBg] = c(DarkTheme::highlight, 0.6f); + colors[ImGuiCol_DockingPreview] = c(DarkTheme::highlight, 0.5f); + colors[ImGuiCol_NavCursor] = highlight; + colors[ImGuiCol_PlotLines] = c(DarkTheme::text); + colors[ImGuiCol_PlotHistogram] = highlight; + colors[ImGuiCol_DragDropTarget] = highlight; + } else { + const ImVec4 window = colorRgb(0xef, 0xef, 0xef); + const ImVec4 base = colorRgb(0xff, 0xff, 0xff); + const ImVec4 outline = colorRgb(0xb9, 0xb9, 0xb9); + const ImVec4 highlight = colorRgb(0x30, 0x8c, 0xc6); + colors[ImGuiCol_WindowBg] = window; + colors[ImGuiCol_ChildBg] = base; + colors[ImGuiCol_PopupBg] = colorRgb(0xfb, 0xfb, 0xfb); + colors[ImGuiCol_MenuBarBg] = window; + colors[ImGuiCol_DockingEmptyBg] = window; + colors[ImGuiCol_Text] = colorRgb(0x00, 0x00, 0x00); + colors[ImGuiCol_TextDisabled] = colorRgb(0xbe, 0xbe, 0xbe); + colors[ImGuiCol_Border] = outline; + colors[ImGuiCol_BorderShadow] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_FrameBg] = base; + colors[ImGuiCol_FrameBgHovered] = colorRgb(0xf7, 0xf7, 0xf7); + colors[ImGuiCol_FrameBgActive] = colorRgb(0xef, 0xef, 0xef); + colors[ImGuiCol_Button] = colorRgb(0xf3, 0xf3, 0xf3); + colors[ImGuiCol_ButtonHovered] = colorRgb(0xf9, 0xf9, 0xf9); + colors[ImGuiCol_ButtonActive] = colorRgb(0xdc, 0xdc, 0xdc); + colors[ImGuiCol_Header] = highlight; + colors[ImGuiCol_HeaderHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.8f); + colors[ImGuiCol_HeaderActive] = highlight; + colors[ImGuiCol_CheckMark] = colorRgb(0x3b, 0x3b, 0x3b); + colors[ImGuiCol_SliderGrab] = colorRgb(0xd8, 0xd8, 0xd8); + colors[ImGuiCol_SliderGrabActive] = colorRgb(0xc4, 0xc4, 0xc4); + colors[ImGuiCol_ScrollbarBg] = window; + colors[ImGuiCol_ScrollbarGrab] = colorRgb(0xc8, 0xc8, 0xc8); + colors[ImGuiCol_ScrollbarGrabHovered] = colorRgb(0xb4, 0xb4, 0xb4); + colors[ImGuiCol_ScrollbarGrabActive] = colorRgb(0xa0, 0xa0, 0xa0); + colors[ImGuiCol_Separator] = outline; + colors[ImGuiCol_SeparatorHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.6f); + colors[ImGuiCol_SeparatorActive] = highlight; + colors[ImGuiCol_ResizeGrip] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_ResizeGripHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.6f); + colors[ImGuiCol_ResizeGripActive] = highlight; + colors[ImGuiCol_Tab] = colorRgb(0xe2, 0xe2, 0xe2); + colors[ImGuiCol_TabHovered] = colorRgb(0xf5, 0xf5, 0xf5); + colors[ImGuiCol_TabSelected] = base; + colors[ImGuiCol_TabSelectedOverline] = highlight; + colors[ImGuiCol_TabDimmed] = colorRgb(0xe2, 0xe2, 0xe2); + colors[ImGuiCol_TabDimmedSelected] = base; + colors[ImGuiCol_TabDimmedSelectedOverline] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_TitleBg] = window; + colors[ImGuiCol_TitleBgActive] = window; + colors[ImGuiCol_TitleBgCollapsed] = window; + colors[ImGuiCol_TableHeaderBg] = colorRgb(0xf2, 0xf2, 0xf2); + colors[ImGuiCol_TableBorderStrong] = outline; + colors[ImGuiCol_TableBorderLight] = colorRgb(0xd8, 0xd8, 0xd8); + colors[ImGuiCol_TableRowBg] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_TableRowBgAlt] = colorRgb(0, 0, 0, 0.03f); + colors[ImGuiCol_TextSelectedBg] = colorRgb(0x30, 0x8c, 0xc6, 0.35f); + colors[ImGuiCol_DockingPreview] = colorRgb(0x30, 0x8c, 0xc6, 0.5f); + colors[ImGuiCol_NavCursor] = highlight; + colors[ImGuiCol_PlotLines] = colorRgb(0x3b, 0x3b, 0x3b); + colors[ImGuiCol_PlotHistogram] = highlight; + colors[ImGuiCol_DragDropTarget] = highlight; + } + // imgui fades the modal dim in over several frames, which reads as the dialog lagging + colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0, 0, 0, 0); + colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0, 0, 0, 0); +} + +bool isDarkTheme() { return g_dark; } + +ImU32 highlightedTextColor() { + return g_dark ? IM_COL32(DarkTheme::window_text.r, DarkTheme::window_text.g, DarkTheme::window_text.b, 255) + : IM_COL32(255, 255, 255, 255); +} + +ImU32 paletteBrightText() { + return g_dark ? IM_COL32(DarkTheme::bright_text.r, DarkTheme::bright_text.g, DarkTheme::bright_text.b, 255) + : IM_COL32(255, 255, 255, 255); +} + +void drawSliderHandle(ImDrawList *p, const ImRect &r) { + const bool dark = isDarkTheme(); + const ImU32 top = dark ? IM_COL32(0x3e, 0x41, 0x43, 255) : IM_COL32(255, 255, 255, 255); + const ImU32 bottom = dark ? IM_COL32(0x39, 0x3c, 0x3e, 255) : IM_COL32(0xf0, 0xf0, 0xf0, 255); + // the top/left edge is one step lighter than the bottom/right edge + const ImU32 outline_top = dark ? IM_COL32(0xa3, 0xa3, 0xa3, 255) : IM_COL32(0xab, 0xab, 0xab, 255); + const ImU32 outline_bottom = dark ? IM_COL32(0x9c, 0x9c, 0x9c, 255) : IM_COL32(0xa4, 0xa4, 0xa4, 255); + p->AddRectFilled(r.Min, r.Max, top, 2.0f); + p->AddRectFilled(ImVec2(r.Min.x, r.GetCenter().y), r.Max, bottom, 2.0f, ImDrawFlags_RoundCornersBottom); + p->AddRect(r.Min, r.Max, outline_bottom, 2.0f, 0, 1.0f); + // the straight edges are drawn as crisp 1 px rects: an antialiased outline washes out to a much lighter grey + const float c = 2.0f; // corner radius + p->AddRectFilled(ImVec2(r.Min.x + c, r.Min.y), ImVec2(r.Max.x - c, r.Min.y + 1.0f), outline_top); + p->AddRectFilled(ImVec2(r.Min.x, r.Min.y + c), ImVec2(r.Min.x + 1.0f, r.Max.y - c), outline_top); + p->AddRectFilled(ImVec2(r.Min.x + c, r.Max.y - 1.0f), ImVec2(r.Max.x - c, r.Max.y), outline_bottom); + p->AddRectFilled(ImVec2(r.Max.x - 1.0f, r.Min.y + c), ImVec2(r.Max.x, r.Max.y - c), outline_bottom); +} + +bool fusionSliderInt(const char *label, int *v, int min, int max, float width) { + // a grey groove over the full width with the part left of the handle filled, and a 13x13 handle on top + const ImU32 groove_col = isDarkTheme() ? IM_COL32(0x2a, 0x2c, 0x2e, 255) : IM_COL32(0xc4, 0xc4, 0xc4, 255); + const ImU32 fill_col = ImGui::GetColorU32(ImGuiCol_Header); + ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_SliderGrab, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_SliderGrabActive, IM_COL32_BLACK_TRANS); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); // the slider has no frame + ImGui::SetNextItemWidth(width); + bool changed = ImGui::SliderInt(label, v, min, max, "", ImGuiSliderFlags_NoInput); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(5); + + const ImVec2 bb_min = ImGui::GetItemRectMin(), bb_max = ImGui::GetItemRectMax(); + const float cy = (bb_min.y + bb_max.y) * 0.5f; + const float groove_h = SLIDER_THICKNESS * 0.5f; + const float handle_h = std::min(SLIDER_THICKNESS, bb_max.y - bb_min.y); + const float x0 = bb_min.x + SLIDER_LENGTH * 0.5f, x1 = bb_max.x - SLIDER_LENGTH * 0.5f; + const float t = max > min ? (float)(*v - min) / (float)(max - min) : 0.0f; + const float hx = x0 + (x1 - x0) * t; + ImDrawList *dl = ImGui::GetWindowDrawList(); + const float groove_y0 = cy - groove_h * 0.5f, groove_y1 = cy + groove_h * 0.5f; + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(bb_max.x, groove_y1), groove_col, groove_h * 0.5f); + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(hx, groove_y1), fill_col, groove_h * 0.5f); + drawSliderHandle(dl, ImRect(ImVec2(hx - SLIDER_LENGTH * 0.5f, cy - handle_h * 0.5f), + ImVec2(hx + SLIDER_LENGTH * 0.5f, cy + handle_h * 0.5f))); + return changed; +} + +ImFont *boldFont() { return g_bold_font; } +ImFont *monoFont() { return g_mono_font; } + +void pushMonoFont(float size) { + if (!g_mono_font) return; + size > 0.0f ? ImGui::PushFont(g_mono_font, size) : ImGui::PushFont(g_mono_font); +} +void popMonoFont() { if (g_mono_font) ImGui::PopFont(); } +void pushBoldFont() { if (g_bold_font) ImGui::PushFont(g_bold_font); } +void popBoldFont() { if (g_bold_font) ImGui::PopFont(); } +void pushLargeFont() { if (g_large_font) ImGui::PushFont(g_large_font); } +void popLargeFont() { if (g_large_font) ImGui::PopFont(); } diff --git a/openpilot/tools/cabana/ui/threadpool.h b/openpilot/tools/cabana/ui/threadpool.h new file mode 100644 index 0000000000..5a45ec80f2 --- /dev/null +++ b/openpilot/tools/cabana/ui/threadpool.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Reusing the same threads matters: a std::async thread per update lands each allocation in a different +// glibc malloc arena and the process RSS grows without bound. +class ThreadPool { +public: + static ThreadPool &instance() { + static ThreadPool pool(std::clamp(std::thread::hardware_concurrency(), 2u, 4u)); + return pool; + } + + std::future run(std::function fn) { + auto task = std::make_shared>(std::move(fn)); + std::future future = task->get_future(); + { + std::lock_guard lk(mutex_); + tasks_.push([task]() { (*task)(); }); + } + cv_.notify_one(); + return future; + } + + ~ThreadPool() { + { + std::lock_guard lk(mutex_); + stop_ = true; + } + cv_.notify_all(); + for (auto &t : threads_) t.join(); + } + +private: + explicit ThreadPool(unsigned n) { + for (unsigned i = 0; i < n; ++i) { + threads_.emplace_back([this]() { + for (;;) { + std::function task; + { + std::unique_lock lk(mutex_); + cv_.wait(lk, [this]() { return stop_ || !tasks_.empty(); }); + if (stop_ && tasks_.empty()) return; + task = std::move(tasks_.front()); + tasks_.pop(); + } + task(); + } + }); + } + } + + std::vector threads_; + std::queue> tasks_; + std::mutex mutex_; + std::condition_variable cv_; + bool stop_ = false; +}; + +// fn(begin, end) over [0, n) split into one chunk per pool thread plus one for the caller, which also +// waits for the others. Not for use from a pool thread. +inline void parallelFor(size_t n, const std::function &fn) { + const size_t chunks = std::clamp(std::thread::hardware_concurrency(), 2, 4) + 1; + const size_t chunk = (n + chunks - 1) / chunks; + if (chunk == 0) return; + std::vector> futures; + size_t begin = chunk; + for (; begin < n; begin += chunk) { + futures.push_back(ThreadPool::instance().run([&fn, begin, end = std::min(begin + chunk, n)]() { fn(begin, end); })); + } + fn(0, std::min(chunk, n)); + for (auto &f : futures) f.get(); +} diff --git a/openpilot/tools/cabana/ui/tools/findsignal.cc b/openpilot/tools/cabana/ui/tools/findsignal.cc new file mode 100644 index 0000000000..1642ac2cc5 --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsignal.cc @@ -0,0 +1,325 @@ +#include "tools/cabana/ui/tools/findsignal.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +namespace { +constexpr int MAX_ROWS = 300; +} // namespace + +void SignalSearch::search(const std::function &cmp) { + const auto prev_sigs = !histories.empty() ? histories.back() : initial_signals; + filtered_signals.clear(); + filtered_signals.reserve(prev_sigs.size()); + + std::mutex lock; + parallelFor(prev_sigs.size(), [&](size_t begin, size_t end) { + for (size_t i = begin; 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; + char buf[64]; + snprintf(buf, sizeof(buf), "(%.3f, %g)", can->toSeconds((*it)->mono_time), get_raw_value((*it)->dat, (*it)->size, s.sig)); + values.push_back(buf); + std::lock_guard lk(lock); + filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values}); + } + } + }); + + histories.push_back(filtered_signals); +} + +void SignalSearch::undo() { + if (!histories.empty()) { + histories.pop_back(); + filtered_signals.clear(); + if (!histories.empty()) filtered_signals = histories.back(); + } +} + +void SignalSearch::reset() { + histories.clear(); + filtered_signals.clear(); + initial_signals.clear(); +} + +FindSignalDlg::FindSignalDlg() { + setTitle("Find Signal"); +} + +FindSignalDlg::~FindSignalDlg() { + if (search_future_.valid()) search_future_.wait(); +} + +bool FindSignalDlg::draw() { + if (search_future_.valid() && search_future_.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + search_future_.get(); + searched_ = true; + } + searching_ = search_future_.valid(); + if (begin(ImVec2(900, 650))) { + float group_w = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) / 2; + ImGui::BeginChild("Messages", ImVec2(group_w, 0), ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY); + drawMessageGroup(); + ImGui::EndChild(); + ImGui::SameLine(); + ImGui::BeginChild("Signal", ImVec2(group_w, 0), ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY); + drawPropertiesGroup(); + ImGui::EndChild(); + float footer = searched_ ? ImGui::GetTextLineHeightWithSpacing() : 0; + ImGui::BeginChild("Find signal", ImVec2(0, -footer), ImGuiChildFlags_Borders); + drawFindGroup(); + ImGui::EndChild(); + if (searched_) { + ImGui::Text("%zu matches. right click on an item to create signal. double click to open message", + search_.filtered_signals.size()); + } + } + return end(); +} + +void FindSignalDlg::drawMessageGroup() { + ImGui::BeginDisabled(searching_ || !search_.histories.empty()); + ImGui::TextUnformatted("Messages"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Bus"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(-1); + inputText("##bus", &bus_, "comma-separated values. Leave blank for all"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Address"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(-1); + inputText("##address", &address_, "comma-separated hex values. Leave blank for all"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Time"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(70); + validatedText("##first_time", &first_time_, validateDouble); + ImGui::SameLine(); + ImGui::TextUnformatted("-"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(70); + validatedText("##last_time", &last_time_, validateDouble); + ImGui::SameLine(); + ImGui::TextUnformatted("seconds"); + ImGui::EndDisabled(); +} + +void FindSignalDlg::drawPropertiesGroup() { + ImGui::BeginDisabled(searching_ || !search_.histories.empty()); + ImGui::TextUnformatted("Signal"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Size"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(70); + if (ImGui::InputInt("##min_size", &min_size_, 1, 10)) min_size_ = std::clamp(min_size_, 1, 64); + ImGui::SameLine(); + ImGui::TextUnformatted("-"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(70); + if (ImGui::InputInt("##max_size", &max_size_, 1, 10)) max_size_ = std::clamp(max_size_, 1, 64); + ImGui::SameLine(); + checkBox("Little endian", &little_endian_); + ImGui::SameLine(); + checkBox("Signed", &is_signed_); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Factor"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(100); + validatedText("##factor", &factor_, validateDouble); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Offset"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(100); + validatedText("##offset", &offset_, validateDouble); + ImGui::EndDisabled(); +} + +void FindSignalDlg::drawFindGroup() { + static const char *compare_items[] = {"=", ">", ">=", "!=", "<", "<=", "between"}; + const int compare_count = IM_ARRAYSIZE(compare_items); + ImGui::TextUnformatted("Find signal"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Value"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90); + ImGui::Combo("##compare", &compare_, compare_items, compare_count); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); + validatedText("##value1", &value1_, validateDouble); + if (compare_ == compare_count - 1) { + ImGui::SameLine(); + ImGui::TextUnformatted("-"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + validatedText("##value2", &value2_, validateDouble); + } + ImGui::SameLine(); + const bool first = !searching_ && search_.histories.empty(); + ImGui::BeginDisabled(searching_ || search_.histories.size() <= 1); + if (ImGui::Button("Undo prev find")) { + search_.undo(); + searched_ = true; + } + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(searching_ || (search_.filtered_signals.empty() && !first)); + if (ImGui::Button(searching_ ? "Finding ...." : (first ? "Find" : "Find Next"))) search(); + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(searching_ || first); + if (ImGui::Button("Reset")) { + search_.reset(); + searched_ = true; + } + ImGui::EndDisabled(); + + if (searching_) { + ImGui::BeginChild("view", ImVec2(0, 0), ImGuiChildFlags_Borders); + ImGui::EndChild(); + } else { + drawTable(); + } +} + +void FindSignalDlg::drawTable() { + static const char *titles[] = {"Id", "Start Bit, size", "(time, value)"}; + const int columns = IM_ARRAYSIZE(titles); + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_NoSavedSettings; + if (!ImGui::BeginTable("view", columns + 1, flags, ImVec2(0, 0))) return; + ImGui::TableSetupScrollFreeze(0, 1); + const int rows = std::min(search_.filtered_signals.size(), MAX_ROWS); + // vertical header: row number, no width while there are no results + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed | (rows ? 0 : ImGuiTableColumnFlags_Disabled), 40.0f); + for (int c = 0; c < columns; ++c) { + auto column_flags = c == columns - 1 ? ImGuiTableColumnFlags_WidthStretch : ImGuiTableColumnFlags_WidthFixed; + ImGui::TableSetupColumn(titles[c], column_flags, c == 0 ? 80.0f : 120.0f); + } + tableHeadersRow(); + for (int row = 0; row < rows; ++row) { + const auto &s = search_.filtered_signals[row]; + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(row); + if (ImGui::Selectable(std::to_string(row + 1).c_str(), false, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) openMessage(s.id); + } + drawContextMenu(row); + ImGui::PopID(); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(s.id.toString().c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::Text("%d, %d", s.sig.start_bit, s.sig.size); + ImGui::TableSetColumnIndex(3); + std::string values; + for (size_t i = 0; i < s.values.size(); ++i) { + if (i) values += " "; + values += s.values[i]; + } + ImGui::TextUnformatted(values.c_str()); + } + ImGui::EndTable(); +} + +void FindSignalDlg::search() { + if (search_.histories.empty()) { + setInitialSignals(); + } + auto v1 = utils::toDouble(value1_); + auto v2 = utils::toDouble(value2_); + std::function cmp = nullptr; + switch (compare_) { + case 0: cmp = [v1](double v) { return v == v1;}; break; + case 1: cmp = [v1](double v) { return v > v1;}; break; + case 2: cmp = [v1](double v) { return v >= v1;}; break; + case 3: cmp = [v1](double v) { return v != v1;}; break; + case 4: cmp = [v1](double v) { return v < v1;}; break; + case 5: cmp = [v1](double v) { return v <= v1;}; break; + case 6: cmp = [v1, v2](double v) { return v >= v1 && v <= v2;}; break; + } + searched_ = false; + // a thread of its own: the search fans out over the pool, which a pool thread must not wait on + search_future_ = std::async(std::launch::async, [this, cmp = std::move(cmp)]() { search_.search(cmp); }); + searching_ = true; +} + +void FindSignalDlg::setInitialSignals() { + std::set buses; + for (auto bus : utils::split(utils::trimmed(bus_), ',')) { + bus = utils::trimmed(bus); + if (!bus.empty()) buses.insert((unsigned short)utils::toULong(bus)); + } + + std::set addresses; + for (auto addr : utils::split(utils::trimmed(address_), ',')) { + addr = utils::trimmed(addr); + if (!addr.empty()) addresses.insert(utils::toULong(addr, 16)); + } + + cabana::Signal sig{}; + sig.is_little_endian = little_endian_; + sig.is_signed = is_signed_; + sig.factor = utils::toDouble(factor_); + sig.offset = utils::toDouble(offset_); + + double first_time_val = utils::toDouble(first_time_); + double last_time_val = utils::toDouble(last_time_); + auto [first_sec, last_sec] = std::minmax(first_time_val, last_time_val); + uint64_t first_time = can->toMonoTime(first_sec); + search_.last_time = std::numeric_limits::max(); + if (last_sec > 0) { + search_.last_time = can->toMonoTime(last_sec); + } + search_.initial_signals.clear(); + + for (const auto &[id, m] : can->lastMessages()) { + 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()) { + const int total_size = m.dat.size() * 8; + for (int size = min_size_; size <= max_size_; ++size) { + for (int start = 0; start <= total_size - size; ++start) { + SignalSearch::SearchSignal s{.id = id, .mono_time = first_time, .sig = sig}; + s.sig.start_bit = start; + s.sig.size = size; + updateMsbLsb(s.sig); + s.value = get_raw_value((*e)->dat, (*e)->size, s.sig); + search_.initial_signals.push_back(s); + } + } + } + } + } +} + +void FindSignalDlg::drawContextMenu(int row) { + if (ImGui::BeginPopupContextItem("menu")) { + if (ImGui::MenuItem("Create Signal")) { + auto &s = search_.filtered_signals[row]; + UndoStack::instance()->push(new AddSigCommand(s.id, s.sig)); + openMessage(s.id); + } + ImGui::EndPopup(); + } +} diff --git a/openpilot/tools/cabana/ui/tools/findsignal.h b/openpilot/tools/cabana/ui/tools/findsignal.h new file mode 100644 index 0000000000..1db9bc47ee --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsignal.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/tools/tooldialog.h" + +struct SignalSearch { + struct SearchSignal { + MessageId id = {}; + uint64_t mono_time = 0; + cabana::Signal sig = {}; + double value = 0.; + std::vector values; + }; + + void search(const std::function &cmp); + void reset(); + void undo(); + + std::vector filtered_signals; + std::vector initial_signals; + std::vector> histories; + uint64_t last_time = std::numeric_limits::max(); +}; + +class FindSignalDlg : public ToolDialog { +public: + FindSignalDlg(); + ~FindSignalDlg() override; + bool draw() override; + + Observable openMessage; + +private: + void search(); + void setInitialSignals(); + void drawContextMenu(int row); + void drawMessageGroup(); + void drawPropertiesGroup(); + void drawFindGroup(); + void drawTable(); + + std::string value1_, value2_, factor_ = "1.0", offset_ = "0.0"; + std::string bus_, address_, first_time_ = "0", last_time_ = "MAX"; + int compare_ = 0; + int min_size_ = 8, max_size_ = 8; + bool little_endian_ = true, is_signed_ = false; + bool searched_ = false; // a search/undo/reset ran, so the stats line is shown + SignalSearch search_; + std::future search_future_; // search_ is off limits while it is valid + bool searching_ = false; +}; diff --git a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc new file mode 100644 index 0000000000..81ae3660a9 --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc @@ -0,0 +1,178 @@ +#include "tools/cabana/ui/tools/findsimilarbits.h" + +#include +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/ui/util.h" + +FindSimilarBitsDlg::FindSimilarBitsDlg() { + setTitle("Find similar bits"); + + for (int bus : can->sources) { + bus_items_.push_back(bus); + } + updateMessages(); +} + +void FindSimilarBitsDlg::updateMessages() { + msg_items_.clear(); + msg_names_.clear(); + for (auto &[address, msg] : dbc()->getMessages(busAt(src_bus_))) { + msg_items_.push_back({msg.name, address}); + } + std::sort(msg_items_.begin(), msg_items_.end(), [](auto &l, auto &r) { return l.first < r.first; }); + for (auto &[name, _] : msg_items_) msg_names_.push_back(name); + msg_index_ = 0; +} + +bool FindSimilarBitsDlg::draw() { + if (begin(ImVec2(700, 500))) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Find From:"); + ImGui::SameLine(90); + ImGui::TextUnformatted("Bus"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + if (comboBox("##src_bus", &src_bus_, bus_items_.data(), (int)bus_items_.size())) updateMessages(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(200); + comboBox("##msg", &msg_index_, msg_names_); + ImGui::SameLine(); + ImGui::TextUnformatted("Byte Index"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + if (ImGui::InputInt("##byte_idx", &byte_idx_, 1, 10)) byte_idx_ = std::clamp(byte_idx_, 0, 63); + ImGui::SameLine(); + ImGui::TextUnformatted("Bit Index"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + if (ImGui::InputInt("##bit_idx", &bit_idx_, 1, 10)) bit_idx_ = std::clamp(bit_idx_, 0, 7); + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Find In:"); + ImGui::SameLine(90); + ImGui::TextUnformatted("Bus"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + comboBox("##find_bus", &find_bus_, bus_items_.data(), (int)bus_items_.size()); + ImGui::SameLine(); + ImGui::TextUnformatted("Equal"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + ImGui::Combo("##equal", &equal_, "Yes\0No\0"); + ImGui::SameLine(); + ImGui::TextUnformatted("Min msg count"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + if (ImGui::InputInt("##min_msgs", &min_msgs_, 1, 10)) min_msgs_ = std::max(min_msgs_, 0); + ImGui::SameLine(); + if (ImGui::Button("Find")) find(); + + drawTable(); + } + return end(); +} + +void FindSimilarBitsDlg::drawTable() { + // columns are set by find(); until then the table is an empty frame + if (!table_has_columns_) { + ImGui::BeginChild("table", ImVec2(0, 0), ImGuiChildFlags_Borders); + ImGui::EndChild(); + return; + } + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_NoSavedSettings; + if (!ImGui::BeginTable("table", 7, flags, ImVec2(0, 0))) return; + ImGui::TableSetupScrollFreeze(0, 1); + static const char *headers[] = {"address", "byte idx", "bit idx", "mismatches", "total msgs", "% mismatched"}; + // the fixed widths are section sizes: imgui adds the cell padding on top of the column width + const float padding = ImGui::GetStyle().CellPadding.x * 2; + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 40.0f - padding); // vertical header: row number + for (int c = 0; c < 6; ++c) { + ImGui::TableSetupColumn(headers[c], c == 5 ? ImGuiTableColumnFlags_WidthStretch : ImGuiTableColumnFlags_WidthFixed, + 100.0f - padding); + } + tableHeadersRow(); + ImGuiListClipper clipper; + clipper.Begin((int)table_.size()); + while (clipper.Step()) { + for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) { + auto &m = table_[i]; + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(i); + if (ImGui::Selectable(std::to_string(i + 1).c_str(), false, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + openMessage(MessageId{.source = busAt(find_bus_), .address = m.address}); + } + } + ImGui::PopID(); + ImGui::TableSetColumnIndex(1); + ImGui::Text("%x", m.address); + ImGui::TableSetColumnIndex(2); + ImGui::Text("%u", m.byte_idx); + ImGui::TableSetColumnIndex(3); + ImGui::Text("%u", m.bit_idx); + ImGui::TableSetColumnIndex(4); + ImGui::Text("%u", m.mismatches); + ImGui::TableSetColumnIndex(5); + ImGui::Text("%u", m.total); + ImGui::TableSetColumnIndex(6); + ImGui::Text("%.2f", m.perc); + } + } + ImGui::EndTable(); +} + +void FindSimilarBitsDlg::find() { + const uint32_t selected_address = msg_index_ < (int)msg_items_.size() ? msg_items_[msg_index_].second : 0; + table_ = calcBits(busAt(src_bus_), selected_address, byte_idx_, bit_idx_, busAt(find_bus_), equal_ == 0, min_msgs_); + table_has_columns_ = true; +} + +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) { + if (e->src == bus) { + if (e->address == selected_address && e->size > byte_idx) { + bit_to_find = ((e->dat[byte_idx] >> (7 - bit_idx)) & 1) != 0; + } + } + if (e->src == find_bus) { + ++msg_count[e->address]; + if (bit_to_find == -1) continue; + + auto &mismatched = mismatches[e->address]; + if (mismatched.size() < e->size * 8) { + mismatched.resize(e->size * 8); + } + for (int i = 0; i < e->size; ++i) { + for (int j = 0; j < 8; ++j) { + int bit = ((e->dat[i] >> (7 - j)) & 1) != 0; + mismatched[i * 8 + j] += equal ? (bit != bit_to_find) : (bit == bit_to_find); + } + } + } + } + + std::vector result; + result.reserve(mismatches.size()); + for (auto it = mismatches.begin(); it != mismatches.end(); ++it) { + 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->first, (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); + } + } + } + } + std::sort(result.begin(), result.end(), [](auto &l, auto &r) { return l.perc < r.perc; }); + return result; +} diff --git a/openpilot/tools/cabana/ui/tools/findsimilarbits.h b/openpilot/tools/cabana/ui/tools/findsimilarbits.h new file mode 100644 index 0000000000..c60bf1562b --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsimilarbits.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/ui/tools/tooldialog.h" + +class FindSimilarBitsDlg : public ToolDialog { +public: + FindSimilarBitsDlg(); + bool draw() override; + + Observable openMessage; + +private: + struct Mismatch { + uint32_t address, byte_idx, bit_idx, mismatches, total; + float perc; + }; + 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); + uint8_t busAt(int index) const { return index < (int)bus_items_.size() ? bus_items_[index] : 0; } + void updateMessages(); + void find(); + void drawTable(); + + std::vector table_; // rows, replaced by find() + bool table_has_columns_ = false; + std::vector bus_items_; // the source and the find bus combos + int src_bus_ = 0, find_bus_ = 0; + std::vector> msg_items_; // (name, address) of the source bus + std::vector msg_names_; + int msg_index_ = 0; + int equal_ = 0; + int byte_idx_ = 0, bit_idx_ = 0; + int min_msgs_ = 100; +}; diff --git a/openpilot/tools/cabana/ui/tools/routeinfo.cc b/openpilot/tools/cabana/ui/tools/routeinfo.cc new file mode 100644 index 0000000000..1761da2a1f --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/routeinfo.cc @@ -0,0 +1,50 @@ +#include "tools/cabana/ui/tools/routeinfo.h" + +#include +#include + +#include "imgui.h" +#include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/ui/util.h" + +RouteInfoDlg::RouteInfoDlg() { + replay_ = dynamic_cast(can)->getReplay(); + setTitle("Route: " + replay_->route().name()); +} + +bool RouteInfoDlg::draw() { + static const char *headers[] = {"", "rlog", "narrow road", "wide road", "driver", "qlog", "qcam"}; + auto yn = [](const std::string &s) { return s.empty() ? "--" : "Yes"; }; + const auto &segments = replay_->route().segments(); + // minimum size: header + min(rowCount, 13) rows + float row_h = ImGui::GetTextLineHeightWithSpacing(); + float min_h = row_h * (std::min((int)segments.size(), 13) + 1) + ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().WindowPadding.y * 2; + if (begin(ImVec2(520, min_h))) { + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingFixedFit; + if (ImGui::BeginTable("table", 7, flags, ImVec2(0, 0))) { + ImGui::TableSetupScrollFreeze(0, 1); + for (int c = 0; c < 7; ++c) ImGui::TableSetupColumn(headers[c]); + tableHeadersRow(); + int row = 0; + for (const auto &[seg_num, seg] : segments) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(row); + if (ImGui::Selectable(std::to_string(seg_num).c_str(), false, ImGuiSelectableFlags_SpanAllColumns)) { + can->seekTo(row * 60.0); + } + ImGui::SetItemTooltip("Click on a row to seek to the corresponding segment."); + ImGui::PopID(); + const char *cells[] = {yn(seg.rlog), yn(seg.narrow_road_cam), yn(seg.wide_road_cam), + yn(seg.cabin_cam), yn(seg.qlog), yn(seg.qcamera)}; + for (int c = 1; c < 7; ++c) { + ImGui::TableSetColumnIndex(c); + ImGui::TextUnformatted(cells[c - 1]); + } + ++row; + } + ImGui::EndTable(); + } + } + return end(); +} diff --git a/openpilot/tools/cabana/ui/tools/routeinfo.h b/openpilot/tools/cabana/ui/tools/routeinfo.h new file mode 100644 index 0000000000..e3d4e11b3d --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/routeinfo.h @@ -0,0 +1,14 @@ +#pragma once + +#include "tools/cabana/ui/tools/tooldialog.h" + +class Replay; + +class RouteInfoDlg : public ToolDialog { +public: + RouteInfoDlg(); + bool draw() override; + +private: + Replay *replay_ = nullptr; // destroyed with the stream, which destroys this dialog first +}; diff --git a/openpilot/tools/cabana/ui/tools/tooldialog.h b/openpilot/tools/cabana/ui/tools/tooldialog.h new file mode 100644 index 0000000000..06807bcd33 --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/tooldialog.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "tools/cabana/core/observable.h" +#include "tools/cabana/ui/util.h" + +// non-modal dialogs drawn by MainWindow every frame until closed +class ToolDialog { +public: + virtual ~ToolDialog() = default; + virtual bool draw() = 0; // false once the dialog was closed + + Connections connections_; // dies with the dialog + +protected: + void setTitle(const std::string &name) { + char buf[32]; + snprintf(buf, sizeof(buf), "###tooldialog%p", (void *)this); + title_ = name + buf; + } + + // draw() body: `if (begin(size)) { content } return end();` + bool begin(const ImVec2 &size) { + if (!open_) return false; + ImGui::SetNextWindowSize(size, ImGuiCond_Appearing); + setNextWindowFloatsOut(); + began_ = true; + return visible_ = ImGui::Begin(title_.c_str(), &open_, ImGuiWindowFlags_NoSavedSettings); + } + + bool end() { + if (!began_) return false; + // Escape closes the dialog like QDialog, but not while a popup is open above it + if (visible_ && ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && + ImGui::IsKeyPressed(ImGuiKey_Escape, false) && + !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) { + open_ = false; + } + ImGui::End(); + began_ = false; + return open_; + } + + std::string title_; + bool open_ = true; + +private: + bool began_ = false, visible_ = false; +}; diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc new file mode 100644 index 0000000000..bd1b2f8039 --- /dev/null +++ b/openpilot/tools/cabana/ui/util.cc @@ -0,0 +1,466 @@ +#include "tools/cabana/ui/util.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#ifdef __APPLE__ +#include +#include +extern "C" { +struct objc_object; +struct objc_selector; +objc_object *glfwGetCocoaWindow(GLFWwindow *window); +objc_selector *sel_registerName(const char *name); +void objc_msgSend(void); +} +#endif + +#include "tools/cabana/ui/icons.h" + +int inputCallback(ImGuiInputTextCallbackData *data) { + auto *ctx = static_cast(data->UserData); + if (data->EventFlag == ImGuiInputTextFlags_CallbackCharFilter) { + return ctx->validator ? ctx->validator(data) : 0; + } + if (data->EventFlag == ImGuiInputTextFlags_CallbackEdit) { + if (ctx->validate(std::string(data->Buf, data->BufTextLen)) == ValidState::Invalid) { + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, ctx->last_valid->c_str()); + } + return 0; + } + if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) { + ctx->str->resize(data->BufTextLen); + data->Buf = ctx->str->data(); + } + return 0; +} + +bool validatedInput(const char *label, std::string *s, ImGuiInputTextCallback validator, const char *hint, + ImGuiInputTextFlags flags) { + InputContext ctx{s, validator}; + flags |= ImGuiInputTextFlags_CallbackResize; + if (validator) flags |= ImGuiInputTextFlags_CallbackCharFilter; + return ImGui::InputTextWithHint(label, hint, s->data(), s->capacity() + 1, flags, inputCallback, &ctx); +} + +bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, ImGuiInputTextFlags flags) { + InputContext ctx{s, nullptr}; + return ImGui::InputTextMultiline(label, s->data(), s->capacity() + 1, size, flags | ImGuiInputTextFlags_CallbackResize, + inputCallback, &ctx); +} + +bool clearableInput(const char *label, std::string *s, const char *hint, ImGuiInputTextCallback validator) { + bool changed = validatedInput(label, s, validator, hint); + if (!s->empty()) { + ImGui::SameLine(0.0f, 0.0f); + ImGui::PushID(label); + if (toolButton("clear", icon::X)) { + s->clear(); + changed = true; + } + ImGui::PopID(); + } + return changed; +} + +bool comboBox(const char *label, int *index, const std::vector &items) { + bool changed = false; + const int count = (int)items.size(); + if (ImGui::BeginCombo(label, *index >= 0 && *index < count ? items[*index].c_str() : "")) { + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + if (ImGui::Selectable(items[i].c_str(), i == *index) && *index != i) { + *index = i; + changed = true; + } + if (i == *index) ImGui::SetItemDefaultFocus(); + ImGui::PopID(); + } + ImGui::EndCombo(); + } + return changed; +} + +bool validatedText(const char *label, std::string *s, ValidState (*validate)(const std::string &), + const char *hint, ImGuiInputTextCallback filter) { + const std::string last_valid = *s; // a refused edit never reaches *s + InputContext ctx{s, filter, validate, &last_valid}; + ImGuiInputTextFlags flags = ImGuiInputTextFlags_CallbackResize | ImGuiInputTextFlags_CallbackEdit; + if (filter) flags |= ImGuiInputTextFlags_CallbackCharFilter; + ImGui::InputTextWithHint(label, hint, s->data(), s->capacity() + 1, flags, inputCallback, &ctx); + return *s != last_valid; +} + +int nameValidator(ImGuiInputTextCallbackData *data) { + // [A-Za-z0-9_], spaces rewritten to '_' + if (data->EventChar == ' ') { + data->EventChar = '_'; + return 0; + } + return (data->EventChar < 128 && (std::isalnum((int)data->EventChar) || data->EventChar == '_')) ? 0 : 1; +} + +int nodeValidator(ImGuiInputTextCallbackData *data) { + // \w+(,\w+)* + return (data->EventChar < 128 && (std::isalnum((int)data->EventChar) || data->EventChar == '_' || data->EventChar == ',')) ? 0 : 1; +} + +int doubleValidator(ImGuiInputTextCallbackData *data) { + // C-locale floating-point + const ImWchar c = data->EventChar; + return (c < 128 && (std::isdigit((int)c) || c == '+' || c == '-' || c == '.' || c == 'e' || c == 'E')) ? 0 : 1; +} + +int ipValidator(ImGuiInputTextCallbackData *data) { + // [0-9.] + const ImWchar c = data->EventChar; + return ((c >= '0' && c <= '9') || c == '.') ? 0 : 1; +} + +int nonWhitespaceValidator(ImGuiInputTextCallbackData *data) { + // \S+ + return (data->EventChar < 128 && std::isspace((int)data->EventChar)) ? 1 : 0; +} + +bool toolButton(const char *id, const char *icon, const char *tooltip, const char *text) { + std::string label = text && *text ? std::string(icon) + " " + text + "###" + id : std::string(icon) + "###" + id; + // no frame, transparent until hovered + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + bool clicked = ImGui::Button(label.c_str()); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + if (tooltip && *tooltip) ImGui::SetItemTooltip("%s", tooltip); + return clicked; +} + +void disabledItemTooltip(const char *text) { + if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip | ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", text); +} + +bool radioMenuItem(const char *label, bool checked, float width) { + const float indent = ImGui::GetFontSize(); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const bool clicked = ImGui::Selectable((std::string("##") + label).c_str(), false, ImGuiSelectableFlags_None, + ImVec2(ImMax(width, ImGui::GetContentRegionAvail().x), 0.0f)); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); + ImDrawList *painter = ImGui::GetWindowDrawList(); + if (checked) ImGui::RenderBullet(painter, ImVec2(pos.x + indent / 2, pos.y + ImGui::GetTextLineHeight() / 2), color); + painter->AddText(ImVec2(pos.x + indent, pos.y), color, label); + return clicked; +} + +bool PopupOwner::begin(const char *id) { + ImGuiWindow *window = ImGui::GetCurrentWindowRead(); // GetCurrentWindow() would mark the fallback window as used + if (popup_id == 0) { + // a pending popup may only be opened from the call nested in the top-most modal, or from any call + // when there is no modal at all + ImGuiWindow *modal = ImGui::GetTopMostPopupModal(); + if (modal != nullptr && modal != window) return false; + ImGui::OpenPopup(id); + popup_id = window->GetID(id); + owner_id = window->ID; + } else if (owner_id != window->ID) { + return false; + } else if (!ImGui::IsPopupOpen(popup_id, ImGuiPopupFlags_AnyPopupLevel)) { + // reopen if imgui closed the popup underneath us (host window change) + ImGui::OpenPopup(id); + } + return true; +} + +ImGuiWindow *topPopupWindow() { + ImGuiContext &g = *GImGui; + return g.OpenPopupStack.Size > 0 ? g.OpenPopupStack.back().Window : nullptr; +} + +bool dialogEscapePressed() { + return ImGui::IsKeyPressed(ImGuiKey_Escape, false) && topPopupWindow() == ImGui::GetCurrentWindow(); +} + +bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, bool accept_enabled, + const char *reject_label) { + const float button_width = 80.0f; + const int count = reject_label ? 2 : 1; + const float total = button_width * count + ImGui::GetStyle().ItemSpacing.x * (count - 1); + const float avail = ImGui::GetContentRegionAvail().x; + if (avail > total) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + avail - total); + bool pressed = false; + if (reject_label) { + if (ImGui::Button(reject_label, ImVec2(button_width, 0.0f))) { + if (rejected) *rejected = true; + pressed = true; + } + ImGui::SameLine(); + } + ImGui::BeginDisabled(!accept_enabled); + if (ImGui::Button(accept_label, ImVec2(button_width, 0.0f))) { + if (accepted) *accepted = true; + pressed = true; + } + ImGui::EndDisabled(); + if (rejected && dialogEscapePressed()) { + *rejected = true; + pressed = true; + } + return pressed; +} + +int tableHeadersRow() { + int clicked = -1; + ImGui::TableNextRow(ImGuiTableRowFlags_Headers); + for (int c = 0, count = ImGui::TableGetColumnCount(); c < count; ++c) { + if (!ImGui::TableSetColumnIndex(c)) continue; + const char *name = ImGui::TableGetColumnName(c); + if (!name) name = ""; + const float offset = (ImGui::GetContentRegionAvail().x - ImGui::CalcTextSize(name).x) * 0.5f; + if (offset > 0) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + offset); + ImGui::PushID(c); + ImGui::TableHeader(name); + // same timing as TableHeader's own TableOpenContextMenu, so a menu opened by the caller is opened last + // and replaces the (disabled) table context menu in the popup stack + if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Right)) clicked = c; + ImGui::PopID(); + } + return clicked; +} + +bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size) { + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, selected ? ImGui::GetColorU32(ImGuiCol_Header) : IM_COL32(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImGui::GetColorU32(ImGuiCol_Header)); + const bool clicked = ImGui::Selectable(label, selected, flags, size); + ImGui::PopStyleColor(2); + return clicked; +} + +bool checkBox(const char *label, bool *v) { + const float box = 16.0f; + ImGuiWindow *window = ImGui::GetCurrentWindow(); + if (window->SkipItems) return false; + const ImGuiStyle &style = ImGui::GetStyle(); + const ImGuiID id = window->GetID(label); + const ImVec2 label_size = ImGui::CalcTextSize(label, nullptr, true); + const float frame_h = ImGui::GetFrameHeight(); + const ImVec2 pos = window->DC.CursorPos; + const ImRect total_bb(pos, ImVec2(pos.x + box + (label_size.x > 0.0f ? style.ItemInnerSpacing.x + label_size.x : 0.0f), pos.y + frame_h)); + ImGui::ItemSize(total_bb, style.FramePadding.y); + if (!ImGui::ItemAdd(total_bb, id)) return false; + bool hovered, held; + const bool pressed = ImGui::ButtonBehavior(total_bb, id, &hovered, &held); + if (pressed) { + *v = !*v; + ImGui::MarkItemEdited(id); + } + const float y = pos.y + IM_TRUNC((frame_h - box) * 0.5f); + const ImRect check_bb(ImVec2(pos.x, y), ImVec2(pos.x + box, y + box)); + ImGui::RenderNavCursor(total_bb, id); + const ImU32 bg = ImGui::GetColorU32((held && hovered) ? ImGuiCol_FrameBgActive : hovered ? ImGuiCol_FrameBgHovered : ImGuiCol_FrameBg); + ImGui::RenderFrame(check_bb.Min, check_bb.Max, bg, true, style.FrameRounding); + if (*v) { + const float pad = ImMax(1.0f, IM_TRUNC(box / 6.0f)); + ImGui::RenderCheckMark(window->DrawList, ImVec2(check_bb.Min.x + pad, check_bb.Min.y + pad), ImGui::GetColorU32(ImGuiCol_CheckMark), box - pad * 2.0f); + } + if (label_size.x > 0.0f) ImGui::RenderText(ImVec2(check_bb.Max.x + style.ItemInnerSpacing.x, pos.y + style.FramePadding.y), label); + return pressed; +} + +void alignRight(float width) { + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, ImGui::GetContentRegionAvail().x - width)); +} + +void drawText(ImDrawList *dl, const ImRect &rect, const char *text, ImU32 col, ImFont *font, float font_size, const ImVec2 &align) { + if (font == nullptr) font = ImGui::GetFont(); + if (font_size <= 0.0f) font_size = ImGui::GetFontSize(); + const ImVec2 size = font->CalcTextSizeA(font_size, FLT_MAX, 0.0f, text); + const ImVec2 pos(rect.Min.x + (rect.GetWidth() - size.x) * align.x, rect.Min.y + (rect.GetHeight() - size.y) * align.y); + dl->AddText(font, font_size, pos, col, text); +} + +void drawElidedText(ImDrawList *dl, const ImRect &rect, const std::string &text, ImU32 col, bool align_right) { + const ImVec2 size = ImGui::CalcTextSize(text.c_str()); + const float y = rect.Min.y + std::max(0.0f, (rect.GetHeight() - size.y) * 0.5f); + if (size.x <= rect.GetWidth()) { + dl->AddText(ImVec2(align_right ? rect.Max.x - size.x : rect.Min.x, y), col, text.c_str()); + } else { + ImGui::PushStyleColor(ImGuiCol_Text, col); + ImGui::RenderTextEllipsis(dl, ImVec2(rect.Min.x, y), ImVec2(rect.Max.x, y + size.y), rect.Max.x, text.c_str(), nullptr, &size); + ImGui::PopStyleColor(); + } +} + +float markerSize() { return ImGui::GetTextLineHeight() - 4; } + +void drawColorMarker(ImDrawList *dl, const ImVec2 &pos, ImU32 col) { + const float size = markerSize(); + dl->AddRectFilled(ImVec2(pos.x, pos.y + 2), ImVec2(pos.x + size, pos.y + 2 + size), col); +} + +#ifdef __APPLE__ +void setMacAppName(const char *name) { + auto info = (CFMutableDictionaryRef)CFBundleGetInfoDictionary(CFBundleGetMainBundle()); + if (info == nullptr) return; + CFStringRef value = CFStringCreateWithCString(kCFAllocatorDefault, name, kCFStringEncodingUTF8); + CFDictionarySetValue(info, CFSTR("CFBundleName"), value); + CFRelease(value); +} + +bool isNativeFullScreen(GLFWwindow *window) { + constexpr unsigned long NS_WINDOW_STYLE_MASK_FULL_SCREEN = 1ul << 14; + objc_object *ns_window = glfwGetCocoaWindow(window); + if (ns_window == nullptr) return false; + auto styleMask = (unsigned long (*)(objc_object *, objc_selector *))objc_msgSend; + return (styleMask(ns_window, sel_registerName("styleMask")) & NS_WINDOW_STYLE_MASK_FULL_SCREEN) != 0; +} + +void toggleNativeFullScreen(GLFWwindow *window) { + auto toggle = (void (*)(objc_object *, objc_selector *, objc_object *))objc_msgSend; + toggle(glfwGetCocoaWindow(window), sel_registerName("toggleFullScreen:"), nullptr); +} +#endif + +void setNextWindowFloatsOut() { + ImGuiWindowClass window_class; + window_class.ViewportFlagsOverrideSet = ImGuiViewportFlags_NoAutoMerge; + ImGui::SetNextWindowClass(&window_class); +} + +void setNextDialogWindow(const ImVec2 &size) { + if (size.x > 0.0f || size.y > 0.0f) ImGui::SetNextWindowSize(size, ImGuiCond_Appearing); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + setNextWindowFloatsOut(); +} + +bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWindowFlags flags) { + if (!owner->begin(id)) return false; + setNextDialogWindow(size); + return ImGui::BeginPopupModal(id, nullptr, flags | ImGuiWindowFlags_NoSavedSettings); +} + +// tool bar + +void beginToolbar() { + // the items sit next to each other, the buttons only carry the auto raise margin + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(TOOLBAR_ITEM_SPACING, ImGui::GetStyle().ItemSpacing.y)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(TOOLBAR_BUTTON_PADDING, ImGui::GetStyle().FramePadding.y)); +} + +void endToolbar() { ImGui::PopStyleVar(2); } + +float toolbarButtonWidth(const std::string &label) { + return ImGui::CalcTextSize(label.c_str(), nullptr, true).x + ImGui::GetStyle().FramePadding.x * 2; +} + +static float toolbarGroupWidth(const std::vector &items, size_t begin, size_t end) { + float w = 0; + for (size_t i = begin; i < end; ++i) w += items[i].width + (i > begin ? ImGui::GetStyle().ItemSpacing.x : 0); + return w; +} + +float toolbarWidth(const std::vector &items, size_t spacer_index) { + spacer_index = std::min(spacer_index, items.size()); + float w = toolbarGroupWidth(items, 0, spacer_index) + toolbarGroupWidth(items, spacer_index, items.size()); + if (spacer_index > 0 && spacer_index < items.size()) w += ImGui::GetStyle().ItemSpacing.x; + return w; +} + +void drawToolbar(const std::vector &items, size_t spacer_index) { + const ImGuiStyle &style = ImGui::GetStyle(); + spacer_index = std::min(spacer_index, items.size()); + const float right_width = toolbarGroupWidth(items, spacer_index, items.size()); + const float start_x = ImGui::GetCursorPosX(); + const float avail = ImGui::GetContentRegionAvail().x; + const float right_edge = start_x + avail; + const float extension_width = toolbarButtonWidth(icon::RAQUO); + + // when everything fits the spacer takes the slack, otherwise the extension button is reserved at the + // right edge and the items are packed from the left until the next one does not fit + const bool fits = toolbarWidth(items, spacer_index) <= avail; + size_t visible = items.size(); + if (!fits) { + const float usable = avail - (extension_width + style.ItemSpacing.x); + float used = 0; + for (visible = 0; visible < items.size(); ++visible) { + const float w = items[visible].width + (visible ? style.ItemSpacing.x : 0); + if (used + w > usable) break; + used += w; + } + } + + for (size_t i = 0; i < visible; ++i) { + if (i == 0) ImGui::SetCursorPosX(start_x); + else if (fits && i == spacer_index) ImGui::SameLine(right_edge - right_width); + else ImGui::SameLine(); + items[i].draw(); + } + + if (visible < items.size()) { + // the extension button sits fully inside the toolbar: its right edge is the content region right edge + const float extension_x = std::max(start_x, right_edge - extension_width); + visible == 0 ? ImGui::SetCursorPosX(extension_x) : ImGui::SameLine(extension_x); + if (ImGui::Button((std::string(icon::RAQUO) + "###toolbar_extension").c_str(), ImVec2(extension_width, 0))) + ImGui::OpenPopup("toolbar_extension_menu"); + ImGui::SetItemTooltip("More"); + // the popup opens inward: its right edge is aligned with the button so it stays inside the window + ImGui::SetNextWindowPos(ImVec2(ImGui::GetItemRectMax().x, ImGui::GetItemRectMax().y), ImGuiCond_Always, ImVec2(1, 0)); + if (ImGui::BeginPopup("toolbar_extension_menu")) { + for (size_t i = visible; i < items.size(); ++i) { + if (!items[i].in_menu) continue; + if (items[i].menu_label.empty()) { + items[i].draw(); + } else if (ImGui::MenuItem(items[i].menu_label.c_str(), nullptr, false, items[i].enabled)) { + items[i].trigger(); + } + } + ImGui::EndPopup(); + } + } +} + +const float MENU_ARROW_SIZE = 6.0f; // dropdown arrow on a menu button +const float MENU_ARROW_SPACING = 5.0f; // gap between the label and the dropdown arrow + +float menuButtonWidth(const std::string &text, bool bold) { + if (bold) pushBoldFont(); + const float w = ImGui::CalcTextSize(text.c_str(), nullptr, true).x + MENU_ARROW_SPACING + MENU_ARROW_SIZE + + ImGui::GetStyle().FramePadding.x * 2; + if (bold) popBoldFont(); + return w; +} + +bool menuButton(const char *id, const std::string &text, const char *popup_id, bool bold, float width) { + const ImGuiStyle &style = ImGui::GetStyle(); + const bool popup_open = ImGui::IsPopupOpen(popup_id); + if (width <= 0.0f) width = menuButtonWidth(text, bold); + // no frame, transparent until hovered; the button is drawn pressed while the menu is open. The menu opens + // on press; a press while it is open toggles it closed (imgui closes the popup at the end of the frame of + // a click outside it, so only open when it is not already open) + if (bold) pushBoldFont(); + ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : ImVec4(0, 0, 0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.0f, 0.5f)); + const bool clicked = ImGui::ButtonEx((text + "###" + id).c_str(), ImVec2(width, 0.0f), ImGuiButtonFlags_PressedOnClick); + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(); + const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; + const float ascent = ImGui::GetFontBaked()->Ascent; + if (bold) popBoldFont(); + // a 6 px arrow right after the text, sitting on the text baseline + const ImVec2 min = ImGui::GetItemRectMin(); + const float x = min.x + style.FramePadding.x + text_width + MENU_ARROW_SPACING; + const float baseline = min.y + style.FramePadding.y + ascent; + ImGui::GetWindowDrawList()->AddTriangleFilled(ImVec2(x, baseline - MENU_ARROW_SIZE * 0.5f), + ImVec2(x + MENU_ARROW_SIZE, baseline - MENU_ARROW_SIZE * 0.5f), + ImVec2(x + MENU_ARROW_SIZE * 0.5f, baseline), + ImGui::GetColorU32(ImGuiCol_TextDisabled)); + if (clicked && !popup_open) ImGui::OpenPopup(popup_id); + // the menu drops down from below the button, not at the mouse cursor + ImGui::SetNextWindowPos(ImVec2(min.x, ImGui::GetItemRectMax().y), ImGuiCond_Always); + return clicked; +} diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h new file mode 100644 index 0000000000..99de047913 --- /dev/null +++ b/openpilot/tools/cabana/ui/util.h @@ -0,0 +1,201 @@ +#pragma once + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/core/color.h" +#include "tools/cabana/utils/util.h" + +struct GLFWwindow; + +inline ImVec4 colorRgb(int r, int g, int b, float alpha = 1.0f) { + return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, alpha); +} + +inline ImU32 toImU32(const CabanaColor &c) { return IM_COL32(c.r, c.g, c.b, c.a); } +inline ImVec4 toImVec4(const CabanaColor &c) { return ImVec4(c.r / 255.0f, c.g / 255.0f, c.b / 255.0f, c.a / 255.0f); } +inline ImU32 withAlpha(ImU32 c, int alpha) { return (c & ~IM_COL32_A_MASK) | ((ImU32)alpha << IM_COL32_A_SHIFT); } + +// the dock window identity of the messages panel (the visible title changes, the part after ### is the id) +constexpr const char *MESSAGES_PANEL_ID = "###MessagesPanel"; + +struct InputContext { + std::string *str; + ImGuiInputTextCallback validator; + ValidState (*validate)(const std::string &) = nullptr; + const std::string *last_valid = nullptr; +}; + +int inputCallback(ImGuiInputTextCallbackData *data); + +// text input with an optional validator; `s` grows through the resize callback +bool validatedInput(const char *label, std::string *s, ImGuiInputTextCallback validator, const char *hint = "", + ImGuiInputTextFlags flags = 0); + +inline bool inputText(const char *label, std::string *s, const char *hint = "", ImGuiInputTextFlags flags = 0) { + return validatedInput(label, s, nullptr, hint, flags); +} + +bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, ImGuiInputTextFlags flags = 0); + +// an input with a trailing clear button once it holds text; true when the text changed +bool clearableInput(const char *label, std::string *s, const char *hint = "", ImGuiInputTextCallback validator = nullptr); + +bool comboBox(const char *label, int *index, const std::vector &items); + +// numeric items (bus ids, bus speeds) are formatted as they are drawn +template +inline bool comboBox(const char *label, int *index, const T *values, int count) { + bool changed = false; + const std::string preview = *index >= 0 && *index < count ? std::to_string(values[*index]) : ""; + if (ImGui::BeginCombo(label, preview.c_str())) { + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + if (ImGui::Selectable(std::to_string(values[i]).c_str(), i == *index) && *index != i) { + *index = i; + changed = true; + } + if (i == *index) ImGui::SetItemDefaultFocus(); + ImGui::PopID(); + } + ImGui::EndCombo(); + } + return changed; +} + +// an edit that makes the text Invalid is refused inside the imgui buffer, like a validated line edit +bool validatedText(const char *label, std::string *s, ValidState (*validate)(const std::string &), + const char *hint = "", ImGuiInputTextCallback filter = nullptr); + +// InputText char filters; the std::string validators in utils/util.h are run again when the edit is committed +int nameValidator(ImGuiInputTextCallbackData *data); +int nodeValidator(ImGuiInputTextCallbackData *data); +int doubleValidator(ImGuiInputTextCallbackData *data); +int ipValidator(ImGuiInputTextCallbackData *data); +int nonWhitespaceValidator(ImGuiInputTextCallbackData *data); + +// auto-raise icon button with a tooltip +bool toolButton(const char *id, const char *icon, const char *tooltip = nullptr, const char *text = nullptr); + +// tooltip for the last item that also shows while the item is disabled +void disabledItemTooltip(const char *text); + +// exclusive menu action: the bullet sits in the check column and the whole row highlights. `width` is the +// minimum row width, so a narrow popup stays wide enough for every row while the highlight spans the popup. +bool radioMenuItem(const char *label, bool checked, float width = 0.0f); + +// A queued modal popup submitted from whichever call site is nested in the top-most modal. draw() is called +// both nested in a modal dialog and at the root level; only the level that opened the popup may submit it +// (opening at level 0 would make imgui close the parent modal). +struct PopupOwner { + ImGuiID popup_id = 0, owner_id = 0; + + // false: this call site must skip the popup this frame + bool begin(const char *id); + + void reset() { popup_id = owner_id = 0; } +}; + +// Escape closes a dialog only when nothing is open above it: a combo drops its list first. +bool dialogEscapePressed(); + +// the window of the top-most open popup, nullptr when none is open +ImGuiWindow *topPopupWindow(); + +// [Cancel] [Accept], right aligned. reject_label = nullptr for an accept-only box. Escape rejects. +bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, bool accept_enabled = true, + const char *reject_label = "Cancel"); + +// horizontal header labels are centered. Returns the column a right click was released on, or -1. +int tableHeadersRow(); + +// no hover highlight, only the selection background. Selectable() prefers HeaderHovered over Header +// whenever the row is hovered, even when it is selected, so a selected row has to keep the selection color +// as its hover color or it looks unselected. +bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size); + +// a 16px box vertically centered in the frame height so rows keep their layout; ImGui::Checkbox draws a +// frame height (22 px) square. +bool checkBox(const char *label, bool *v); + +// the next items on the line are right aligned as a block `width` wide +void alignRight(float width); + +// text drawn inside a rect; align 0 = left/top, 0.5 = center, 1 = right/bottom. font_size 0: the current size +void drawText(ImDrawList *dl, const ImRect &rect, const char *text, ImU32 col, ImFont *font = nullptr, + float font_size = 0.0f, const ImVec2 &align = ImVec2(0.5f, 0.5f)); +// elided to the rect width, vertically centered +void drawElidedText(ImDrawList *dl, const ImRect &rect, const std::string &text, ImU32 col, bool align_right = false); +// the colored square in front of a signal name: a text line minus 4 px, drawn 2 px below `pos` +float markerSize(); +void drawColorMarker(ImDrawList *dl, const ImVec2 &pos, ImU32 col); + +void loadFonts(); +void applyTheme(int theme); // safe to call at runtime +bool isDarkTheme(); // the theme applyTheme() resolved + +ImU32 highlightedTextColor(); +ImU32 paletteBrightText(); + +// the next window is a real OS window instead of being drawn inside the main one +void setNextWindowFloatsOut(); +#ifdef __APPLE__ +// the app menu takes its name from the main bundle, and a bare binary gets an info dictionary with its +// file name in it. That dictionary is mutable, so the name is set before glfw brings up cocoa +void setMacAppName(const char *name); +// the native Cocoa full screen toggle (glfw's monitor switch is not full screen on macOS) +bool isNativeFullScreen(GLFWwindow *window); +void toggleNativeFullScreen(GLFWwindow *window); +#endif + +// centered, floating out, sized on first appearance +void setNextDialogWindow(const ImVec2 &size); +// centered modal dialog. false when the popup is not submitted this frame. +bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize); + +const float TOOLBAR_ITEM_SPACING = 1.0f; +const float TOOLBAR_BUTTON_PADDING = 4.0f; // auto raise button horizontal margin +const float SLIDER_LENGTH = 13.0f; +const float SLIDER_THICKNESS = 13.0f; + +// a tool bar item: `draw` submits it. Items that do not fit go into a ">>" menu, where an item with a +// menu_label becomes a MenuItem that runs `trigger`, and an item without one draws itself. +struct ToolbarItem { + float width; + std::function draw; + std::string menu_label; + std::function trigger; + bool enabled = true; + bool in_menu = true; // false: left out of the ">>" menu (a separator) +}; +void beginToolbar(); // item spacing and button padding of a tool bar, until endToolbar() +void endToolbar(); +float toolbarButtonWidth(const std::string &label); +// the width of every item plus the spacing between neighbors and the two groups +float toolbarWidth(const std::vector &items, size_t spacer_index); +// items before spacer_index sit at the left, the rest are right aligned; the overflow goes into the ">>" menu +void drawToolbar(const std::vector &items, size_t spacer_index); + +// an auto-raise button that opens `popup_id` below itself, with a dropdown arrow after the text. width 0: +// sized to the text, otherwise the arrow sits at the right edge +float menuButtonWidth(const std::string &text, bool bold = false); +bool menuButton(const char *id, const std::string &text, const char *popup_id, bool bold = false, float width = 0.0f); + +// a 13x13 handle filled with a subtle vertical gradient and a mid grey outline +void drawSliderHandle(ImDrawList *p, const ImRect &r); + +// full width groove, filled left of the handle, 13x13 handle (style.cc) +bool fusionSliderInt(const char *label, int *v, int min, int max, float width); + +ImFont *boldFont(); +ImFont *monoFont(); +void pushMonoFont(float size = 0.0f); // 0: the size the font was loaded at +void popMonoFont(); +void pushBoldFont(); +void popBoldFont(); +void pushLargeFont(); +void popLargeFont(); diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc new file mode 100644 index 0000000000..87e7213ea8 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -0,0 +1,548 @@ +#include "tools/cabana/ui/widgets/binaryview.h" + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +namespace { + +const int CELL_HEIGHT = 36; +const float SMALL_FONT_SIZE = 10.0f; // Inter needs 10 px for a 7 px cap height +const int VERTICAL_HEADER_WIDTH = 30; +inline int get_bit_pos(const BinaryIndex &index) { return flipBitPos(index.row * 8 + index.column); } + +inline ImU32 paletteHighlight() { return ImGui::GetColorU32(ImGuiCol_Header); } +inline ImU32 paletteBase() { return ImGui::GetColorU32(ImGuiCol_ChildBg); } +inline ImU32 paletteText(bool active) { return ImGui::GetColorU32(active ? ImGuiCol_Text : ImGuiCol_TextDisabled); } +const ImU32 DARK_GRAY = IM_COL32(128, 128, 128, 255); + +// JetBrains Mono ships no bold variant, so emulate one by drawing the glyphs again a fraction of a +// pixel to the right. Keeps the monospace advance, unlike switching to the proportional bold face. +void drawBoldText(ImDrawList *p, const ImRect &r, const char *text, ImU32 col, ImFont *font, float font_size) { + drawText(p, r, text, col, font, font_size); + drawText(p, ImRect(ImVec2(r.Min.x + 0.6f, r.Min.y), ImVec2(r.Max.x + 0.6f, r.Max.y)), text, col, font, font_size); +} + +// sparse dots +void fillDense7Pattern(ImDrawList *p, const ImRect &r, ImU32 col) { + p->PushClipRect(r.Min, r.Max, true); + for (float y = r.Min.y; y < r.Max.y; y += 4.0f) { + for (float x = r.Min.x + (static_cast((y - r.Min.y) / 4.0f) % 2) * 2.0f; x < r.Max.x; x += 4.0f) { + p->AddRectFilled(ImVec2(x, y), ImVec2(x + 1.0f, y + 1.0f), col); + } + } + p->PopClipRect(); +} + +// backward diagonal lines +void fillBDiagPattern(ImDrawList *p, const ImRect &r, ImU32 col) { + p->PushClipRect(r.Min, r.Max, true); + const float h = r.GetHeight(); + for (float x = r.Min.x - h; x < r.Max.x; x += 8.0f) { + p->AddLine(ImVec2(x, r.Max.y), ImVec2(x + h, r.Min.y), col, 1.0f); + } + p->PopClipRect(); +} + +} // namespace + +BinaryView::BinaryView() { + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); +} + +std::string BinaryView::whatsThis() const { + return R"( + Binary View
+ Shortcuts
+ Delete Signal: +  x , +  Backspace , +  Delete 
+ Change endianness:  e 
+ Change signedness:  s 
+ Open chart: +  c , +  p , +  g  + )"; +} + +void BinaryView::addShortcuts() { + const ImGuiIO &io = ImGui::GetIO(); + if (io.WantTextInput || io.KeyCtrl || io.KeySuper) return; + if (ImGui::GetTopMostPopupModal() != nullptr) return; // a modal dialog blocks the shortcuts + + if (ImGui::IsKeyPressed(ImGuiKey_X, false) || ImGui::IsKeyPressed(ImGuiKey_Backspace, false) || ImGui::IsKeyPressed(ImGuiKey_Delete, false)) { + if (hovered_sig_ != nullptr) { + UndoStack::instance()->push(new RemoveSigCommand(msg_id_, hovered_sig_)); + hovered_sig_ = nullptr; + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_E, false)) { + if (hovered_sig_ != nullptr) { + cabana::Signal s = *hovered_sig_; + s.is_little_endian = !s.is_little_endian; + editSignal(hovered_sig_, s); + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_S, false)) { + if (hovered_sig_ != nullptr) { + cabana::Signal s = *hovered_sig_; + s.is_signed = !s.is_signed; + editSignal(hovered_sig_, s); + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_P, false) || ImGui::IsKeyPressed(ImGuiKey_G, false) || ImGui::IsKeyPressed(ImGuiKey_C, false)) { + if (hovered_sig_ != nullptr) { + showChart(msg_id_, hovered_sig_, true, false); + } + } +} + +ImVec2 BinaryView::minimumSizeHint() const { + // widest fixed-font glyph plus the header margins + pushMonoFont(); + const float min_section_size = ImGui::CalcTextSize("W").x + 8.0f; + popMonoFont(); + return {(min_section_size + 1) * 9 + VERTICAL_HEADER_WIDTH + 2, + static_cast(CELL_HEIGHT * std::min(row_count_, 10) + 2)}; +} + +void BinaryView::highlight(const cabana::Signal *sig) { + if (sig != hovered_sig_) { + hovered_sig_ = sig; + signalHovered(hovered_sig_); + } +} + +void BinaryView::setSelection() { + auto index = indexAt(last_mouse_pos_); + if (!anchor_index_.isValid() || !index.isValid()) + return; + + std::set selection; + auto [start, size, is_lb] = getSelection(index); + for (int i = 0; i < size; ++i) { + int pos = is_lb ? flipBitPos(start + i) : flipBitPos(start) + i; + selection.insert({pos / 8, pos % 8}); + } + selection_ = std::move(selection); +} + +void BinaryView::handleMousePress(const ImVec2 &pos) { + resize_sig_ = nullptr; + if (auto index = indexAt(last_mouse_pos_ = pos); index.isValid() && index.column != HEX_COLUMN) { + anchor_index_ = index; + auto item = &cellAt(anchor_index_); + int bit_pos = get_bit_pos(anchor_index_); + for (auto s : item->sigs) { + if (bit_pos == s->lsb || bit_pos == s->msb) { + int idx = flipBitPos(bit_pos == s->lsb ? s->msb : s->lsb); + anchor_index_ = {idx / 8, idx % 8}; + resize_sig_ = s; + break; + } + } + } +} + +void BinaryView::highlightPosition(const ImVec2 &pos) { + if (auto index = indexAt(pos); index.isValid()) { + auto item = &cellAt(index); + const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back(); + highlight(sig); + } +} + +void BinaryView::handleMouseMove(const ImVec2 &pos) { + highlightPosition(last_mouse_pos_ = pos); + // drag selecting while the left button is down; the hex column is not selectable + if (ImGui::IsMouseDown(ImGuiMouseButton_Left) && indexAt(pos).column != HEX_COLUMN) setSelection(); +} + +void BinaryView::handleMouseRelease(const ImVec2 &pos) { + auto release_index = indexAt(pos); + if (release_index.isValid() && anchor_index_.isValid()) { + if (hasSelection()) { + auto sig = resize_sig_ ? *resize_sig_ : cabana::Signal{}; + std::tie(sig.start_bit, sig.size, sig.is_little_endian) = getSelection(release_index); + resize_sig_ ? editSignal(resize_sig_, sig) + : UndoStack::instance()->push(new AddSigCommand(msg_id_, sig)); + } else { + auto item = &cellAt(anchor_index_); + if (item->sigs.size() > 0) + signalClicked(item->sigs.back()); + } + } + selection_.clear(); + anchor_index_ = BinaryIndex(); + resize_sig_ = nullptr; +} + +void BinaryView::setMessage(const MessageId &message_id) { + msg_id_ = message_id; + scroll_to_top_ = true; + refresh(); +} + +void BinaryView::refresh() { + selection_.clear(); + anchor_index_ = BinaryIndex(); + resize_sig_ = nullptr; + hovered_sig_ = nullptr; + bit_flip_tracker_ = {}; + cells_.clear(); + if (auto dbc_msg = dbc()->msg(msg_id_)) { + row_count_ = dbc_msg->size; + cells_.resize(row_count_ * COLUMN_COUNT); + for (auto sig : dbc_msg->getSignals()) { + for (int j = 0; j < sig->size; ++j) { + 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 >= cells_.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 ? cells_[idx].is_lsb = true : cells_[idx].is_msb = true; + if (j == sig->size - 1) sig->is_little_endian ? cells_[idx].is_msb = true : cells_[idx].is_lsb = true; + + auto &sigs = cells_[idx].sigs; + sigs.push_back(sig); + if (sigs.size() > 1) { + std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) { return l->size > r->size; }); + } + } + } + } else { + row_count_ = can->lastMessage(msg_id_).dat.size(); + cells_.resize(row_count_ * COLUMN_COUNT); + } + updateState(); + if (under_mouse_) highlightPosition(last_mouse_pos_); +} + + +std::set BinaryView::getOverlappingSignals() const { + std::set overlapping; + for (const auto &item : cells_) { + if (item.sigs.size() > 1) { + for (auto s : item.sigs) { + if (s->type == cabana::Signal::Type::Normal) overlapping.insert(s); + } + } + } + return overlapping; +} + +std::tuple BinaryView::getSelection(BinaryIndex index) { + if (index.column == HEX_COLUMN) { + index = {index.row, 7}; + } + bool is_lb = true; + if (resize_sig_) { + is_lb = resize_sig_->is_little_endian; + } else if (settings.drag_direction == Settings::DragDirection::MsbFirst) { + is_lb = index < anchor_index_; + } else if (settings.drag_direction == Settings::DragDirection::LsbFirst) { + is_lb = !(index < anchor_index_); + } else if (settings.drag_direction == Settings::DragDirection::AlwaysLE) { + is_lb = true; + } else if (settings.drag_direction == Settings::DragDirection::AlwaysBE) { + is_lb = false; + } + + int cur_bit_pos = get_bit_pos(index); + int anchor_bit_pos = get_bit_pos(anchor_index_); + int start_bit = is_lb ? std::min(cur_bit_pos, anchor_bit_pos) : get_bit_pos(std::min(index, anchor_index_)); + int size = is_lb ? std::abs(cur_bit_pos - anchor_bit_pos) + 1 : std::abs(flipBitPos(cur_bit_pos) - flipBitPos(anchor_bit_pos)) + 1; + return {start_bit, size, is_lb}; +} + +BinaryIndex BinaryView::indexAt(const ImVec2 &pos) const { + if (column_width_ <= 0 || pos.x < grid_pos_.x + VERTICAL_HEADER_WIDTH || pos.y < grid_pos_.y) return {}; + int column = static_cast((pos.x - grid_pos_.x - VERTICAL_HEADER_WIDTH) / column_width_); + int row = static_cast((pos.y - grid_pos_.y) / CELL_HEIGHT); + if (column >= COLUMN_COUNT || row >= row_count_) return {}; + return {row, column}; +} + +ImRect BinaryView::visualRect(const BinaryIndex &index) const { + // sections are integral: round the edges so neighboring cells share them exactly and the cells are + // painted edge to edge with no grid line between them + const float x0 = grid_pos_.x + VERTICAL_HEADER_WIDTH + IM_ROUND(index.column * column_width_); + const float x1 = grid_pos_.x + VERTICAL_HEADER_WIDTH + IM_ROUND((index.column + 1) * column_width_); + const float y = grid_pos_.y + index.row * CELL_HEIGHT; + return ImRect(x0, y, x1, y + CELL_HEIGHT); +} + +void BinaryView::draw() { + is_message_active_ = can->isMessageActive(msg_id_); + if (scroll_to_top_) { + ImGui::SetScrollY(0.0f); + scroll_to_top_ = false; + } + + const int rows = row_count_; + const float width = ImGui::GetContentRegionAvail().x; + column_width_ = std::max(1.0f, (width - VERTICAL_HEADER_WIDTH) / COLUMN_COUNT); + grid_pos_ = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton("##binary_view", ImVec2(std::max(width, 1.0f), std::max(static_cast(rows * CELL_HEIGHT), 1.0f))); + ImDrawList *painter = ImGui::GetWindowDrawList(); + + for (int row = 0; row < rows; ++row) { + const ImRect r(grid_pos_.x, grid_pos_.y + row * CELL_HEIGHT, grid_pos_.x + VERTICAL_HEADER_WIDTH, grid_pos_.y + (row + 1) * CELL_HEIGHT); + painter->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); // plain header background + drawText(painter, r, std::to_string(row).c_str(), ImGui::GetColorU32(ImGuiCol_Text)); + } + for (int row = 0; row < rows; ++row) { + for (int column = 0; column < COLUMN_COUNT; ++column) { + const BinaryIndex index = {row, column}; + paintCell(painter, visualRect(index), index); + } + } + + const ImVec2 mouse = ImGui::GetMousePos(); + const bool hovered = ImGui::IsItemHovered(); + const bool active = ImGui::IsItemActive(); + const bool under_mouse = (hovered || active) && ImGui::IsMouseHoveringRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), false); + if (hovered || active) { + if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) handleMousePress(mouse); + const ImVec2 delta = ImGui::GetIO().MouseDelta; + if (delta.x != 0.0f || delta.y != 0.0f) { + handleMouseMove(mouse); + } else { + // imgui only reports a delta on the frames the mouse actually moves, so recompute the hovered + // signal every frame the mouse is inside the widget, or the shortcuts stay inert after a click + highlightPosition(last_mouse_pos_ = mouse); + } + } + // the mouse left the widget rect, also while dragging + if (std::exchange(under_mouse_, under_mouse) && !under_mouse) highlight(nullptr); + if (ImGui::IsItemDeactivated()) handleMouseRelease(mouse); + + if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip)) { + if (auto index = indexAt(mouse); index.isValid() && !cellAt(index).sigs.empty()) { + ImGui::SetTooltip("%s", utils::stripHtml(utils::signalToolTip(cellAt(index).sigs.back())).c_str()); + } + } + + addShortcuts(); +} + +void BinaryView::setCell(int row, int col, uint8_t val, const CabanaColor &color) { + auto &item = cells_[row * COLUMN_COUNT + col]; + item.valid = true; + item.val = val; + item.bg_color = color; +} + +void BinaryView::updateState() { + const auto &last_msg = can->lastMessage(msg_id_); + const auto &binary = last_msg.dat; + if (binary.size() > row_count_) { + row_count_ = binary.size(); + cells_.resize(row_count_ * COLUMN_COUNT); + } + + auto &bit_flips = heatmap_live_mode_ ? last_msg.bit_flip_counts : bitFlipChanges(binary.size()); + uint32_t max_bit_flip_count = 1; // 1 to avoid division by zero + for (const auto &row : bit_flips) { + for (uint32_t count : row) { + max_bit_flip_count = std::max(max_bit_flip_count, count); + } + } + + const bool dark = isDarkTheme(); + const double max_alpha = 255.0; + const double min_alpha_with_signal = dark ? 70.0 : 25.0; // Base alpha for small flip counts + const double min_alpha_no_signal = dark ? 28.0 : 10.0; // Base alpha for small flip counts for no signal bits + const double alpha_gamma = dark ? 0.6 : 1.0; + const double log_factor = 1.0 + 0.2; + const double log_scaler = max_alpha / log2(log_factor * max_bit_flip_count); + + for (size_t i = 0; i < binary.size(); ++i) { + for (int j = 0; j < 8; ++j) { + auto &item = cells_[i * COLUMN_COUNT + j]; + int bit_val = (binary[i] >> (7 - j)) & 1; + + double alpha = item.sigs.empty() ? 0 : min_alpha_with_signal; + uint32_t flip_count = bit_flips[i][j]; + if (flip_count > 0) { + double normalized_alpha = log2(1.0 + flip_count * log_factor) * log_scaler; + normalized_alpha = max_alpha * std::pow(std::clamp(normalized_alpha / max_alpha, 0.0, 1.0), alpha_gamma); + double min_alpha = item.sigs.empty() ? min_alpha_no_signal : min_alpha_with_signal; + alpha = std::clamp(normalized_alpha, min_alpha, max_alpha); + } + + auto color = item.bg_color; + color.a = static_cast(alpha); + setCell(i, j, bit_val, color); + } + setCell(i, HEX_COLUMN, binary[i], last_msg.colors[i]); + } +} + +const std::vector> &BinaryView::bitFlipChanges(size_t msg_size) { + auto time_range = can->timeRange(); + if (bit_flip_tracker_.time_range == time_range && !bit_flip_tracker_.flip_counts.empty()) + return bit_flip_tracker_.flip_counts; + + bit_flip_tracker_.time_range = time_range; + bit_flip_tracker_.flip_counts.assign(msg_size, std::array{}); + + auto [first, last] = can->eventsInRange(msg_id_, time_range); + if (std::distance(first, last) <= 1) return bit_flip_tracker_.flip_counts; + + std::vector prev_values((*first)->dat, (*first)->dat + (*first)->size); + for (auto it = std::next(first); it != last; ++it) { + const CanEvent *event = *it; + int size = std::min(msg_size, event->size); + for (int i = 0; i < size; ++i) { + const uint8_t diff = event->dat[i] ^ prev_values[i]; + if (!diff) continue; + + auto &bit_flips = bit_flip_tracker_.flip_counts[i]; + for (int bit = 0; bit < 8; ++bit) { + if (diff & (1u << bit)) ++bit_flips[7 - bit]; + } + prev_values[i] = event->dat[i]; + } + } + + return bit_flip_tracker_.flip_counts; +} + +bool BinaryView::hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const { + if (!index.isValid()) return false; + int idx = (index.row + dy) * COLUMN_COUNT + index.column + dx; + if (idx < 0 || idx >= (int)cells_.size()) return false; + auto &s = cells_[idx].sigs; + return std::find(s.begin(), s.end(), sig) != s.end(); +} + +void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index) const { + auto item = &cellAt(index); + ImFont *font = ImGui::GetFont(); + float font_size = ImGui::GetFontSize(); + ImU32 pen = paletteText(is_message_active_); + + if (index.column == HEX_COLUMN) { + if (item->valid) { + pushMonoFont(); + font = ImGui::GetFont(); + font_size = ImGui::GetFontSize(); + popMonoFont(); + painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + } + } else if (isSelected(index)) { + auto color = resize_sig_ ? toImU32(resize_sig_->color) : paletteHighlight(); + painter->AddRectFilled(rect.Min, rect.Max, color); + pen = paletteBrightText(); + } else if (!hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), resize_sig_) == item->sigs.end()) { // not resizing + if (item->sigs.size() > 0) { + for (auto &s : item->sigs) { + if (s == hovered_sig_) { + painter->AddRectFilled(rect.Min, rect.Max, toImU32(s->color.darker(125))); // 4/5x brightness + } else { + drawSignalCell(painter, rect, index, s); + } + } + } else if (item->valid && item->bg_color.alpha() > 0) { + painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + } + bool bright = std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); + pen = bright ? paletteBrightText() : paletteText(is_message_active_); + } + + if (item->sigs.size() > 1) { + fillDense7Pattern(painter, rect, DARK_GRAY); + } else if (!item->valid) { + fillBDiagPattern(painter, rect, DARK_GRAY); + } + if (item->valid) { + if (index.column == HEX_COLUMN) { + drawBoldText(painter, rect, utils::hexByte(item->val), pen, font, font_size); + } else { + drawText(painter, rect, item->val ? "1" : "0", pen, font, font_size); + } + } + if (item->is_msb || item->is_lsb) { + const ImRect marker_rect(rect.Min, ImVec2(rect.Max.x - 8, rect.Max.y - 3)); + drawText(painter, marker_rect, item->is_msb ? "M" : "L", pen, nullptr, SMALL_FONT_SIZE, ImVec2(1.0f, 1.0f)); + } +} + +// Draw border on edge of signal +void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const { + bool draw_left = !hasSignal(index, -1, 0, sig); + bool draw_top = !hasSignal(index, 0, -1, sig); + bool draw_right = !hasSignal(index, 1, 0, sig); + bool draw_bottom = !hasSignal(index, 0, 1, sig); + + const int spacing = 2; + ImRect rc(rect.Min.x + draw_left * 3, rect.Min.y + draw_top * spacing, rect.Max.x - draw_right * 3, rect.Max.y - draw_bottom * spacing); + std::vector subtract; + if (!draw_top) { + if (!draw_left && !hasSignal(index, -1, -1, sig)) { + subtract.emplace_back(rc.Min.x, rc.Min.y, rc.Min.x + 3, rc.Min.y + spacing); + } else if (!draw_right && !hasSignal(index, 1, -1, sig)) { + subtract.emplace_back(rc.Max.x - 3, rc.Min.y, rc.Max.x, rc.Min.y + spacing); + } + } + if (!draw_bottom) { + if (!draw_left && !hasSignal(index, -1, 1, sig)) { + subtract.emplace_back(rc.Min.x, rc.Max.y - spacing, rc.Min.x + 3, rc.Max.y); + } else if (!draw_right && !hasSignal(index, 1, 1, sig)) { + subtract.emplace_back(rc.Max.x - 3, rc.Max.y - spacing, rc.Max.x, rc.Max.y); + } + } + // rc split into horizontal bands with the notch corners removed: at most one notch in the top band and + // one in the bottom band + const ImRect *top_notch = !subtract.empty() && subtract.front().Min.y == rc.Min.y ? &subtract.front() : nullptr; + const ImRect *bottom_notch = !subtract.empty() && subtract.back().Min.y != rc.Min.y ? &subtract.back() : nullptr; + std::vector region; + auto band = [&](const ImRect *notch, float y0, float y1) { + const float x0 = notch && notch->Min.x == rc.Min.x ? notch->Max.x : rc.Min.x; + const float x1 = notch && notch->Min.x != rc.Min.x ? notch->Min.x : rc.Max.x; + if (x1 > x0 && y1 > y0) region.emplace_back(x0, y0, x1, y1); + }; + if (top_notch) band(top_notch, rc.Min.y, rc.Min.y + spacing); + band(nullptr, rc.Min.y + (top_notch ? spacing : 0), rc.Max.y - (bottom_notch ? spacing : 0)); + if (bottom_notch) band(bottom_notch, rc.Max.y - spacing, rc.Max.y); + + auto item = &cellAt(index); + CabanaColor color = sig->color; + color.a = item->bg_color.alpha(); + const ImU32 edge = toImU32(sig->color.darker(125)); + + for (const ImRect &clip : region) { + painter->PushClipRect(clip.Min, clip.Max, true); + // mix the signal color with the background to fade it + painter->AddRectFilled(rc.Min, rc.Max, paletteBase()); + painter->AddRectFilled(rc.Min, rc.Max, toImU32(color)); + + if (draw_left) painter->AddLine(ImVec2(rc.Min.x + 0.5f, rc.Min.y), ImVec2(rc.Min.x + 0.5f, rc.Max.y), edge, 1.0f); + if (draw_right) painter->AddLine(ImVec2(rc.Max.x - 0.5f, rc.Min.y), ImVec2(rc.Max.x - 0.5f, rc.Max.y), edge, 1.0f); + if (draw_bottom) painter->AddLine(ImVec2(rc.Min.x, rc.Max.y - 0.5f), ImVec2(rc.Max.x, rc.Max.y - 0.5f), edge, 1.0f); + if (draw_top) painter->AddLine(ImVec2(rc.Min.x, rc.Min.y + 0.5f), ImVec2(rc.Max.x, rc.Min.y + 0.5f), edge, 1.0f); + + // fill gaps inside corners: the 2px stroke is clipped to the region, only the half outside the notch is painted + for (auto &r : subtract) { + painter->AddRect(r.Min, r.Max, edge, 0.0f, 0, 2.0f); + } + painter->PopClipRect(); + } +} diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.h b/openpilot/tools/cabana/ui/widgets/binaryview.h new file mode 100644 index 0000000000..1ca3c17bab --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/binaryview.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/core/observable.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" + +// a (row, column) of the bit grid: 8 bit columns and the hex column +struct BinaryIndex { + int row = -1; + int column = -1; + bool isValid() const { return row >= 0 && column >= 0; } + bool operator==(const BinaryIndex &o) const { return row == o.row && column == o.column; } + bool operator<(const BinaryIndex &o) const { return std::tie(row, column) < std::tie(o.row, o.column); } +}; + +class BinaryView { +public: + static constexpr int COLUMN_COUNT = 9; + static constexpr int HEX_COLUMN = 8; + + BinaryView(); + void setMessage(const MessageId &message_id); + void highlight(const cabana::Signal *sig); + std::set getOverlappingSignals() const; + void updateState(); + // draws inline into the current (scrollable) window and handles the mouse/keyboard + void draw(); + ImVec2 minimumSizeHint() const; + void setHeatmapLiveMode(bool live) { heatmap_live_mode_ = live; updateState(); } + std::string whatsThis() const; + + Observable signalClicked; + Observable signalHovered; + Observable editSignal; + Observable showChart; + +private: + struct Cell { + CabanaColor bg_color = CabanaColor(102, 86, 169, 255); + bool is_msb = false; + bool is_lsb = false; + uint8_t val; + std::vector sigs; + bool valid = false; + }; + + void refresh(); // rebuilds the grid from the DBC message + void setCell(int row, int col, uint8_t val, const CabanaColor &color); + const std::vector> &bitFlipChanges(size_t msg_size); + Cell &cellAt(const BinaryIndex &index) { return cells_[index.row * COLUMN_COUNT + index.column]; } + const Cell &cellAt(const BinaryIndex &index) const { return cells_[index.row * COLUMN_COUNT + index.column]; } + + void addShortcuts(); // polled every frame from draw() + std::tuple getSelection(BinaryIndex index); + void setSelection(); + void handleMousePress(const ImVec2 &pos); + void handleMouseMove(const ImVec2 &pos); + void handleMouseRelease(const ImVec2 &pos); + void highlightPosition(const ImVec2 &pt); + BinaryIndex indexAt(const ImVec2 &pos) const; + ImRect visualRect(const BinaryIndex &index) const; + bool hasSelection() const { return !selection_.empty(); } + bool isSelected(const BinaryIndex &index) const { return selection_.count(index) > 0; } + + void paintCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index) const; + bool hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const; + void drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const; + + MessageId msg_id_; + std::vector cells_; + int row_count_ = 0; + bool heatmap_live_mode_ = true; + struct BitFlipTracker { + std::optional> time_range; + std::vector> flip_counts; + } bit_flip_tracker_; + + BinaryIndex anchor_index_; + ImVec2 last_mouse_pos_{-1, -1}; + bool is_message_active_ = false; + const cabana::Signal *resize_sig_ = nullptr; + const cabana::Signal *hovered_sig_ = nullptr; + std::set selection_; + ImVec2 grid_pos_; // viewport origin of the current frame + float column_width_ = 0; // stretched section size + bool under_mouse_ = false; + bool scroll_to_top_ = false; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.cc b/openpilot/tools/cabana/ui/widgets/cameraview.cc new file mode 100644 index 0000000000..78ca15116b --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/cameraview.cc @@ -0,0 +1,176 @@ +#include "tools/cabana/ui/widgets/cameraview.h" + +#include +#include +#include + +#include +#include "imgui_impl_opengl3_loader.h" + +#include "common/yuv.h" +#include "tools/cabana/utils/util.h" + +namespace { +constexpr GLenum GL_LINEAR_MIPMAP_LINEAR_ = 0x2703; +// glGenerateMipmap is not part of the imgui GL loader +void generateMipmap() { + static auto fn = (void (*)(GLenum))glfwGetProcAddress("glGenerateMipmap"); + if (fn) fn(GL_TEXTURE_2D); +} +} // namespace + +void GlTexture::upload(const RgbImage &image) { + if (id == 0) { + glGenTextures(1, &id); + glBindTexture(GL_TEXTURE_2D, id); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, mipmap ? GL_LINEAR_MIPMAP_LINEAR_ : 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); + } else { + glBindTexture(GL_TEXTURE_2D, id); + } + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + if (width != image.width || height != image.height) { + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image.width, image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); + width = image.width; + height = image.height; + } else { + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); + } + if (mipmap) generateMipmap(); + glBindTexture(GL_TEXTURE_2D, 0); +} + +void GlTexture::destroy() { + if (id != 0) { + glDeleteTextures(1, &id); + } + id = 0; + width = height = 0; + key = 0; +} + +CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type) + : stream_name_(stream_name), active_stream_type_(type), requested_stream_type_(type) {} + +CameraWidget::~CameraWidget() { + stopVipcThread(); +} + +void CameraWidget::startVipcThread() { + if (!vipc_thread_.joinable()) { + clearFrames(); + vipc_exit_ = false; + vipc_thread_ = std::thread(&CameraWidget::vipcThread, this); + } +} + +void CameraWidget::stopVipcThread() { + vipc_exit_ = true; + if (vipc_thread_.joinable()) { + vipc_thread_.join(); + } +} + +void CameraWidget::setVisible(bool visible) { + if (visible == visible_) return; + visible_ = visible; + visible ? startVipcThread() : stopVipcThread(); +} + +void CameraWidget::draw(const ImVec2 &size) { + setVisible(true); + ImGui::InvisibleButton("##camera", ImVec2(std::max(1.0f, size.x), std::max(1.0f, size.y)), + ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight | ImGuiButtonFlags_MouseButtonMiddle); + rect_ = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + paint(); + if (ImGui::IsItemDeactivated()) clicked(); +} + +float CameraWidget::frameAspectRatio() const { + if (frame_texture_.width > 0 && frame_texture_.height > 0) { + return (float)frame_texture_.width / frame_texture_.height; + } + return 1928.0f / 1208.0f; // the road camera, until the first frame arrives +} + +void CameraWidget::paint() { + ImDrawList *p = ImGui::GetWindowDrawList(); + p->AddRectFilled(rect_.Min, rect_.Max, bg_); + + std::lock_guard lk(frame_lock_); + if (rgb_frame_.isNull()) return; + if (frame_updated_) { + frame_texture_.upload(rgb_frame_); + frame_updated_ = false; + } + + // Scale for aspect ratio + float widget_ratio = (float)width() / height(); + 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)); + ImVec2 video_min(rect_.Min.x + (int)(width() - w) / 2, rect_.Min.y + (int)(height() - h) / 2); + ImVec2 video_max(video_min.x + w, video_min.y + h); + + ImVec2 uv0(0, 0), uv1(1, 1); + if (active_stream_type_ == VISION_STREAM_CABIN) { + // mirror cabin camera horizontally + uv0.x = 1; + uv1.x = 0; + } + p->AddImage(frame_texture_.ref(), video_min, video_max, uv0, uv1); +} + +void CameraWidget::vipcThread() { + VisionStreamType cur_stream = requested_stream_type_; + std::unique_ptr vipc_client; + VisionIpcBufExtra frame_meta = {}; + + while (!vipc_exit_) { + if (!vipc_client || cur_stream != requested_stream_type_) { + clearFrames(); + cur_stream = requested_stream_type_; + vipc_client.reset(new VisionIpcClient(stream_name_, cur_stream, false)); + } + active_stream_type_ = cur_stream; + + if (!vipc_client->connected) { + clearFrames(); + auto streams = VisionIpcClient::getAvailableStreams(stream_name_, false); + if (streams.empty()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + utils::runOnMainThread(utils::guarded(alive_, [this, streams]() { availableStreamsUpdated(streams); })); + + if (!vipc_client->connect(false)) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + } + + 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_.resize(buf->width, buf->height); + } + yuv::nv12_to_rgba(buf->y, buf->stride, buf->uv, buf->stride, + rgb_back_.data.data(), rgb_back_.bytesPerLine(), buf->width, buf->height); + { + std::lock_guard lk(frame_lock_); + rgb_frame_.swap(rgb_back_); + frame_updated_ = true; + } + } + } +} + +void CameraWidget::clearFrames() { + std::lock_guard lk(frame_lock_); + rgb_frame_.reset(); + rgb_back_.reset(); + frame_updated_ = false; +} diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.h b/openpilot/tools/cabana/ui/widgets/cameraview.h new file mode 100644 index 0000000000..381e13dc50 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/cameraview.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "openpilot/cereal/visionstream.h" +#include "tools/cabana/core/observable.h" +#include "msgq/visionipc/visionipc_client.h" + +// tightly packed RGBA pixels +struct RgbImage { + int width = 0; + int height = 0; + std::vector data; + bool isNull() const { return data.empty(); } + void reset() { width = height = 0; data.clear(); } + void resize(int w, int h) { width = w; height = h; data.resize(size_t(w) * h * 4); } + int bytesPerLine() const { return width * 4; } + void swap(RgbImage &other) { std::swap(width, other.width); std::swap(height, other.height); data.swap(other.data); } +}; + +// GL texture holding an RgbImage. Created/uploaded/freed on the GUI thread only (the GL context is current there). +struct GlTexture { + GlTexture() = default; + GlTexture(const GlTexture &) = delete; + GlTexture &operator=(const GlTexture &) = delete; + ~GlTexture() { destroy(); } + void upload(const RgbImage &image); // (re)allocates when the size changes + void destroy(); + ImTextureRef ref() const { return ImTextureRef((ImTextureID)(uintptr_t)id); } + + unsigned int id = 0; + int width = 0; + int height = 0; + uint64_t key = 0; // caller-defined identity of the uploaded image + bool mipmap = false; // set before the first upload: a mip chain for images drawn downscaled +}; + +class CameraWidget { +public: + explicit CameraWidget(std::string stream_name, VisionStreamType stream_type); + ~CameraWidget(); + void setStreamType(VisionStreamType type) { requested_stream_type_ = type; } + void stopVipcThread(); + // draw() implies visible; the owner calls setVisible(false) when the widget is no longer drawn, which + // stops the vipc thread. + void setVisible(bool visible); + // draws an item of `size` into the current window + void draw(const ImVec2 &size); + const ImRect &rect() const { return rect_; } + float frameAspectRatio() const; + float width() const { return rect_.GetWidth(); } + float height() const { return rect_.GetHeight(); } + + Observable<> clicked; + Observable> availableStreamsUpdated; // invoked on the main thread + +private: + void paint(); + void startVipcThread(); + void vipcThread(); + void clearFrames(); + + ImU32 bg_ = IM_COL32(0, 0, 0, 255); + RgbImage rgb_frame_; // written by vipc thread, drawn by GUI thread; guarded by frame_lock_ + RgbImage rgb_back_; // vipc thread only + bool frame_updated_ = false; // rgb_frame_ changed since the last upload; guarded by frame_lock_ + GlTexture frame_texture_; // GUI thread only + ImRect rect_; + bool visible_ = false; + + std::string stream_name_; + std::atomic active_stream_type_; + std::atomic requested_stream_type_; + std::thread vipc_thread_; + std::atomic vipc_exit_ = false; + std::mutex frame_lock_; + std::shared_ptr alive_ = std::make_shared(true); +}; diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc new file mode 100644 index 0000000000..ebb74ffa4a --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -0,0 +1,439 @@ +#include "tools/cabana/ui/widgets/detailwidget.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/commands.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +namespace { + +bool iequals(const std::string &a, const std::string &b) { + return a.size() == b.size() && + std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) { return std::tolower((unsigned char)x) == std::tolower((unsigned char)y); }); +} + +} // namespace + +ElidedLabel::ElidedLabel(const std::string &text) : text_(utils::trimmed(text)) {} + +void ElidedLabel::draw(float width) { + ImGuiWindow *window = ImGui::GetCurrentWindow(); + const ImVec2 pos(window->DC.CursorPos.x, window->DC.CursorPos.y + window->DC.CurrLineTextBaseOffset); + const ImRect bb(pos, ImVec2(pos.x + width, pos.y + ImGui::GetTextLineHeight())); + ImGui::ItemSize(bb.GetSize(), 0.0f); + if (ImGui::ItemAdd(bb, 0)) { + ImGui::RenderTextEllipsis(window->DrawList, bb.Min, bb.Max, bb.Max.x, text_.c_str(), nullptr, nullptr); + } + if (!tooltip_.empty()) ImGui::SetItemTooltip("%s", tooltip_.c_str()); + if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + clicked(); + } +} + +DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { + tabbar_.setUsesScrollButtons(true); + tabbar_.setAutoHide(true); + tabbar_.setTabsClosable(true); + connections_.push_back(tabbar_.currentChanged.connect([this](int index) { + if (index >= 0) setMessage(MessageId::fromString(tabbar_.tabText(index))); + })); + connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { tabbar_.removeTab(index); })); + connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { showTabBarContextMenu(index); })); + binary_view_ = std::make_unique(); + signal_view_ = std::make_unique(charts); + + history_log_ = std::make_unique(); + + connections_.push_back(binary_view_->signalHovered.connect([this](const cabana::Signal *s) { signal_view_->signalHovered(s); })); + connections_.push_back(binary_view_->signalClicked.connect([this](const cabana::Signal *s) { signal_view_->selectSignal(s, true); })); + connections_.push_back(binary_view_->editSignal.connect([this](const cabana::Signal *origin_s, cabana::Signal &s) { signal_view_->saveSignal(origin_s, s); })); + connections_.push_back(binary_view_->showChart.connect([this](const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { charts_->showChart(id, sig, show, merge); })); + connections_.push_back(signal_view_->showChart.connect([this](const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { charts_->showChart(id, sig, show, merge); })); + connections_.push_back(signal_view_->highlight.connect([this](const cabana::Signal *sig) { binary_view_->highlight(sig); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); + connections_.push_back(charts->seriesChanged.connect([this]() { signal_view_->updateChartState(); })); + connections_.push_back(can->timeRangeChanged.connect([this](const std::optional> &range) { + char text[64]; + if (range) snprintf(text, sizeof(text), "%.3f - %.3f", range->first, range->second); + heatmap_all_text_ = range ? text : "All"; + const bool live = !range; + if (std::exchange(heatmap_live_, live) != live) binary_view_->setHeatmapLiveMode(live); + })); +} + +void DetailWidget::drawToolBar() { + const ImGuiStyle &style = ImGui::GetStyle(); + auto radio_width = [&](const char *label) { return ImGui::GetFrameHeight() + style.ItemInnerSpacing.x + ImGui::CalcTextSize(label).x; }; + auto button_width = [&](const char *label) { return ImGui::CalcTextSize(label).x + style.FramePadding.x * 2; }; + const float right_width = ImGui::CalcTextSize("Heatmap:").x + style.ItemSpacing.x + radio_width("Live") + style.ItemSpacing.x + + radio_width(heatmap_all_text_.c_str()) + style.ItemSpacing.x * 3 + 1.0f + + button_width(icon::PENCIL) + style.ItemSpacing.x + button_width(icon::X_LG); + const float avail = ImGui::GetContentRegionAvail().x; + + ImGui::AlignTextToFramePadding(); + pushBoldFont(); + name_label_.draw(std::max(1.0f, avail - right_width - style.ItemSpacing.x)); + popBoldFont(); + + alignRight(right_width); + ImGui::TextUnformatted("Heatmap:"); + ImGui::SameLine(); + if (ImGui::RadioButton("Live##heatmap_live_", heatmap_live_) && !heatmap_live_) { + heatmap_live_ = true; + binary_view_->setHeatmapLiveMode(true); + } + ImGui::SameLine(); + if (ImGui::RadioButton((heatmap_all_text_ + "##heatmap_all").c_str(), !heatmap_live_) && heatmap_live_) { + heatmap_live_ = false; + binary_view_->setHeatmapLiveMode(false); + } + + ImGui::SameLine(); + ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); + ImGui::SameLine(); + if (ImGui::Button(icon::PENCIL)) editMsg(); + ImGui::SetItemTooltip("Edit Message"); + ImGui::SameLine(); + ImGui::BeginDisabled(!action_remove_msg_enabled_); + if (ImGui::Button(icon::X_LG)) UndoStack::instance()->push(new RemoveMsgCommand(msg_id_)); + ImGui::EndDisabled(); + disabledItemTooltip("Remove Message"); +} + +void DetailWidget::showTabBarContextMenu(int index) { + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem("Close Other Tabs")) { + tabbar_.moveTab(index, 0); + tabbar_.setCurrentIndex(0); + while (tabbar_.count() > 1) tabbar_.removeTab(1); + } + ImGui::EndPopup(); + } +} + +int DetailWidget::findOrAddTab(const MessageId &message_id) { + const std::string text = message_id.toString(); + int index = tabbar_.count() - 1; + for (/**/; index >= 0; --index) { + if (tabbar_.tabText(index) == text) break; + } + if (index == -1) { + index = tabbar_.addTab(text); + tabbar_.setTabToolTip(index, msgName(message_id)); + } + return index; +} + +void DetailWidget::setMessage(const MessageId &message_id) { + if (std::exchange(msg_id_, message_id) == message_id) return; + + tabbar_.setCurrentIndex(findOrAddTab(message_id)); + + signal_view_->setMessage(msg_id_); + binary_view_->setMessage(msg_id_); + history_log_->setMessage(msg_id_); + refresh(); +} + +std::pair> DetailWidget::serializeMessageIds() const { + std::vector msgs; + for (int i = 0; i < tabbar_.count(); ++i) msgs.push_back(tabbar_.tabText(i)); + return std::make_pair(msg_id_.toString(), msgs); +} + +void DetailWidget::restoreTabs(const std::string &active_msg_id, const std::vector& msg_ids) { + for (const auto& str_id : msg_ids) { + MessageId id = MessageId::fromString(str_id); + if (dbc()->msg(id) != nullptr) + findOrAddTab(id); + } + + auto active_id = MessageId::fromString(active_msg_id); + if (dbc()->msg(active_id) != nullptr) + setMessage(active_id); +} + +void DetailWidget::refresh() { + std::vector warnings; + auto msg = dbc()->msg(msg_id_); + if (msg) { + if (msg_id_.source == INVALID_SOURCE) { + warnings.push_back("No messages received."); + } else if (msg->size != can->lastMessage(msg_id_).dat.size()) { + warnings.push_back("Message size (" + std::to_string(msg->size) + ") is incorrect."); + } + for (auto s : binary_view_->getOverlappingSignals()) { + warnings.push_back(s->name + " has overlapping bits."); + } + } + std::string msg_name = msg ? msg->name + " (" + msg->transmitter + ")" : msgName(msg_id_); + name_label_.setText(msg_name); + name_label_.setToolTip(msg_name); + action_remove_msg_enabled_ = msg != nullptr; + + if (!warnings.empty()) { + warning_label_.clear(); + for (size_t i = 0; i < warnings.size(); ++i) { + if (i) warning_label_ += '\n'; + warning_label_ += warnings[i]; + } + warning_icon_ = msg ? icon::EXCLAMATION_TRIANGLE : icon::INFO_CIRCLE; + } + warning_widget_visible_ = !warnings.empty(); +} + +void DetailWidget::updateState(const std::set *msgs) { + if ((msgs && !msgs->count(msg_id_))) + return; + + if (tab_widget_index_ == 0) + binary_view_->updateState(); + else + history_log_->updateState(); +} + +void DetailWidget::editMsg() { + auto msg = dbc()->msg(msg_id_); + int size = msg ? msg->size : can->lastMessage(msg_id_).dat.size(); + edit_dlg_ = std::make_unique(msg_id_, msgName(msg_id_), size, ImGui::GetWindowWidth()); +} + +void DetailWidget::drawTabWidget() { + // the pages first, the tab bar below them + const float tab_height = ImGui::GetFrameHeight(); + const float content_height = ImGui::GetContentRegionAvail().y - tab_height - ImGui::GetStyle().ItemSpacing.y; + ImGui::BeginChild("tab_widget", ImVec2(0, std::max(content_height, 1.0f)), ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + if (tab_widget_index_ == 0) { + // binary_view_ keeps its size hint, signal_view_ takes the rest + const float min_height = binary_view_->minimumSizeHint().y; + const float avail = ImGui::GetContentRegionAvail().y; + const float max_height = std::max(avail - 6.0f - ImGui::GetStyle().ItemSpacing.y * 2 - 1.0f, 1.0f); + const float height = std::clamp(min_height, 1.0f, max_height); + ImGui::BeginChild("binary_view", ImVec2(0, height)); + binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); + binary_view_->draw(); + ImGui::EndChild(); + ImGui::Dummy(ImVec2(0.0f, 6.0f)); + const float spacing = ImGui::GetStyle().ItemSpacing.y; + const ImRect child_rect = ImGui::GetCurrentWindow()->Rect(); + ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(child_rect.Min.x, ImGui::GetItemRectMin().y - spacing), + ImVec2(child_rect.Max.x, ImGui::GetItemRectMax().y + spacing), + ImGui::GetColorU32(ImGuiCol_WindowBg)); + ImGui::BeginChild("signal_view", ImVec2(0, 0)); + signal_view_rect_ = ImGui::GetCurrentWindow()->Rect(); + signal_view_->draw(); + ImGui::EndChild(); + } else { + history_log_->draw(); + } + ImGui::EndChild(); + + const std::string labels[] = {std::string(icon::FILE_EARMARK_RULED) + " Messages", std::string(icon::STOPWATCH) + " Logs"}; + // the tabs are centered in the bar: the bar itself starts at the first tab, so its separator only spans the + // tabs and the full width one is drawn underneath it + const ImGuiStyle &style = ImGui::GetStyle(); + float tabs_width = 0.0f; + for (int i = 0; i < 2; ++i) { + tabs_width += ImGui::TabItemCalcSize(labels[i].c_str(), false).x + (i ? style.ItemInnerSpacing.x : 0.0f); + } + ImGuiWindow *window = ImGui::GetCurrentWindow(); + const float separator_y = ImGui::GetCursorScreenPos().y + ImGui::GetFrameHeight() - 1.0f; + window->DrawList->AddLine(ImVec2(window->WorkRect.Min.x, separator_y), ImVec2(window->WorkRect.Max.x, separator_y), + ImGui::GetColorU32(ImGuiCol_TabSelected), style.TabBarBorderSize); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (ImGui::GetContentRegionAvail().x - tabs_width) * 0.5f)); + + if (ImGui::BeginTabBar("tab_widget_tabs")) { + for (int i = 0; i < 2; ++i) { + if (ImGui::BeginTabItem(labels[i].c_str())) { + if (tab_widget_index_ != i) { + tab_widget_index_ = i; + if (i == 1) history_log_->onShown(); + updateState(); + } + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } +} + +void DetailWidget::draw() { + tabbar_.draw(); + drawToolBar(); + + if (warning_widget_visible_) { + ImGui::TextUnformatted(warning_icon_); + ImGui::SameLine(); + ImGui::TextUnformatted(warning_label_.c_str()); + } + + drawTabWidget(); + + if (edit_dlg_ && !edit_dlg_->draw()) { + if (edit_dlg_->accepted()) { + const auto r = edit_dlg_->result(); + UndoStack::instance()->push(new EditMsgCommand(r.msg_id, r.name, r.size, r.node, r.comment)); + } + edit_dlg_.reset(); + } +} + +// HelpOverlay: the whatsThis text and last drawn rect of the binary view and the signal view +std::vector> DetailWidget::helpRects() const { + std::vector> rects; + if (tab_widget_index_ == 0) { + rects.emplace_back(binary_view_->whatsThis(), binary_view_rect_); + rects.emplace_back(signal_view_->whatsThis(), signal_view_rect_); + } + return rects; +} + +EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const std::string &title, int size, float parent_width) + : msg_id_(msg_id), original_name_(title), name_edit_(title), size_spin_(size), width_(parent_width * 0.9f) { + window_title_ = "Edit message: " + msg_id.toString(); + + if (auto msg = dbc()->msg(msg_id)) { + node_ = msg->transmitter; + comment_edit_ = msg->comment; + } + validateName(name_edit_); +} + +EditMessageDialog::Result EditMessageDialog::result() const { + return {msg_id_, utils::trimmed(name_edit_), utils::trimmed(node_), utils::trimmed(comment_edit_), size_spin_}; +} + +bool EditMessageDialog::draw() { + if (closed_) return false; + if (!opened_) { + ImGui::OpenPopup(window_title_.c_str()); + opened_ = true; + } + setNextDialogWindow(ImVec2(0.0f, 0.0f)); + ImGui::SetNextWindowSize(ImVec2(width_, 0.0f), ImGuiCond_Always); // fixed width, the height fits the form + bool open = true; + if (ImGui::BeginPopupModal(window_title_.c_str(), &open)) { + const float label_width = ImGui::CalcTextSize("Comment").x + ImGui::GetStyle().ItemSpacing.x * 2; + auto row = [&](const char *label) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + ImGui::SameLine(label_width); + ImGui::SetNextItemWidth(-FLT_MIN); + }; + + if (!error_label_.empty()) { + row(""); + ImGui::TextUnformatted(error_label_.c_str()); + } + row("Name"); + if (validatedInput("##name", &name_edit_, nameValidator)) { + validateName(name_edit_); + } + + row("Size"); + if (ImGui::InputInt("##size", &size_spin_)) size_spin_ = std::clamp(size_spin_, 1, CAN_MAX_DATA_BYTES); + + row("Node"); + validatedInput("##node", &node_, nameValidator); + row("Comment"); + inputTextMultiline("##comment", &comment_edit_, ImVec2(-FLT_MIN, 192.0f)); + const bool comment_active = ImGui::IsItemActive(); + + bool accept = false, reject = false; + if (dialogButtons("OK", &accept, &reject, ok_enabled_)) { + accepted_ = accept; + closed_ = true; + } + // Enter triggers the default (OK) button + if (!closed_ && ok_enabled_ && !comment_active && ImGui::IsKeyPressed(ImGuiKey_Enter, false)) { + accepted_ = true; + closed_ = true; + } + if (!open) closed_ = true; + if (closed_) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } else { + closed_ = true; // closed from outside + } + return !closed_; +} + +void EditMessageDialog::validateName(const std::string &text) { + bool valid = !iequals(text, UNTITLED); + error_label_.clear(); + if (!text.empty() && valid && text != original_name_) { + valid = dbc()->msg(msg_id_.source, text) == nullptr; + if (!valid) error_label_ = "Name already exists"; + } + ok_enabled_ = valid; +} + +DetailWidget* CenterWidget::ensureDetailWidget() { + if (!detail_widget) { + detail_widget = std::make_unique(charts_); + } + return detail_widget.get(); +} + +void CenterWidget::clear() { + detail_widget.reset(); + charts_ = nullptr; // MainWindow recreates the ChartsWidget after startStream +} + +void CenterWidget::draw() { + if (detail_widget) { + detail_widget->draw(); + } else { + drawWelcomeWidget(); + } +} + +void CenterWidget::drawWelcomeWidget() { + const ImVec2 win_pos = ImGui::GetWindowPos(), win_size = ImGui::GetWindowSize(); + ImGui::GetWindowDrawList()->AddRectFilled(win_pos, ImVec2(win_pos.x + win_size.x, win_pos.y + win_size.y), ImGui::GetColorU32(ImGuiCol_ChildBg)); + + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const ImVec2 origin = ImGui::GetCursorPos(); + auto centered = [&](const char *text, float y) { + const ImVec2 size = ImGui::CalcTextSize(text); + ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - size.x) * 0.5f, y)); + ImGui::TextUnformatted(text); + }; + ImGui::PushStyleColor(ImGuiCol_Text, colorRgb(169, 169, 169)); + float y = origin.y + avail.y * 0.5f - 90.0f; + pushLargeFont(); + centered("CABANA", y); + y += ImGui::GetTextLineHeightWithSpacing(); + popLargeFont(); + + auto newShortcutRow = [&](const char *title, const char *key) { + const float w = ImGui::CalcTextSize(title).x + ImGui::CalcTextSize(key).x + 40.0f; + ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - w) * 0.5f, y)); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(title); + ImGui::SameLine(); + ImGui::BeginDisabled(); + ImGui::SmallButton(key); + ImGui::EndDisabled(); + y += ImGui::GetFrameHeightWithSpacing(); + }; + + centered("<-Select a message to view details", y); + y += ImGui::GetTextLineHeightWithSpacing(); + newShortcutRow("Pause", "Space"); + newShortcutRow("Help", "F1"); + newShortcutRow("WhatsThis", "Shift+F1"); + ImGui::PopStyleColor(); +} diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.h b/openpilot/tools/cabana/ui/widgets/detailwidget.h new file mode 100644 index 0000000000..08d3d7db41 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.h @@ -0,0 +1,112 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "tools/cabana/ui/widgets/binaryview.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/widgets/historylog.h" +#include "tools/cabana/ui/widgets/signalview.h" +#include "tools/cabana/ui/widgets/tabbar.h" + +// a label that elides its text to the available width +class ElidedLabel { +public: + explicit ElidedLabel(const std::string &text = {}); + void setText(const std::string &text) { text_ = text; } + void setToolTip(const std::string &tip) { tooltip_ = tip; } + void draw(float width); + + Observable<> clicked; + +private: + std::string text_, tooltip_; +}; + +// modal, non-blocking: DetailWidget::draw() polls draw() and applies the result once it returns false +class EditMessageDialog { +public: + struct Result { + MessageId msg_id; + std::string name, node, comment; // trimmed + int size; + }; + + EditMessageDialog(const MessageId &msg_id, const std::string &title, int size, float parent_width); + bool draw(); // false once closed + bool accepted() const { return accepted_; } + Result result() const; + +private: + void validateName(const std::string &text); + + MessageId msg_id_; + std::string original_name_; + std::string name_edit_; + std::string node_; + std::string comment_edit_; + std::string error_label_; // empty when the name is valid + int size_spin_; + bool ok_enabled_ = true; + std::string window_title_; + float width_; + bool opened_ = false; + bool accepted_ = false; + bool closed_ = false; +}; + +class DetailWidget { +public: + DetailWidget(ChartsWidget *charts); + void setMessage(const MessageId &message_id); + void refresh(); + void draw(); // tab bar of message ids, toolbar, warning, Messages/Logs tabs + std::pair> serializeMessageIds() const; + void restoreTabs(const std::string &active_msg_id, const std::vector &msg_ids); + std::vector> helpRects() const; // HelpOverlay: (whatsThis, rect) of the binary and signal views + +private: + void drawToolBar(); + void drawTabWidget(); + int findOrAddTab(const MessageId& message_id); + void showTabBarContextMenu(int index); + void editMsg(); + void updateState(const std::set *msgs = nullptr); + + MessageId msg_id_; + const char *warning_icon_ = nullptr; + std::string warning_label_; + ElidedLabel name_label_; + bool warning_widget_visible_ = false; + TabBar tabbar_; + int tab_widget_index_ = 0; + bool action_remove_msg_enabled_ = false; + bool heatmap_live_ = true; + std::string heatmap_all_text_ = "All"; + ImRect binary_view_rect_, signal_view_rect_; // child window rects of the last drawTabWidget + std::unique_ptr history_log_; + std::unique_ptr binary_view_; + std::unique_ptr signal_view_; + ChartsWidget *charts_; + std::unique_ptr edit_dlg_; + Connections connections_; +}; + +class CenterWidget { +public: + CenterWidget() = default; + void setChartsWidget(ChartsWidget *charts) { charts_ = charts; } + void setMessage(const MessageId &message_id) { ensureDetailWidget()->setMessage(message_id); } + DetailWidget* getDetailWidget() { return detail_widget.get(); } + DetailWidget* ensureDetailWidget(); + void clear(); + void draw(); // the welcome widget until a message is selected, then the DetailWidget + +private: + void drawWelcomeWidget(); + std::unique_ptr detail_widget; + ChartsWidget *charts_ = nullptr; +}; diff --git a/openpilot/tools/cabana/ui/widgets/historylog.cc b/openpilot/tools/cabana/ui/widgets/historylog.cc new file mode 100644 index 0000000000..0a3de51d70 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/historylog.cc @@ -0,0 +1,307 @@ +#include "tools/cabana/ui/widgets/historylog.h" + +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/widgets/messagebytes.h" +#include "tools/cabana/utils/export.h" +#include "tools/cabana/utils/strings.h" + +namespace { + +constexpr int BATCH_SIZE = 50; +constexpr float DISPLAY_TYPE_WIDTH = 90.0f; +constexpr float SIGNALS_WIDTH = 160.0f; +constexpr float COMPARE_WIDTH = 50.0f; + +std::string formatTime(uint64_t mono_time) { + char buf[32] = {}; + snprintf(buf, sizeof(buf), "%.3f", can->toSeconds(mono_time)); + return buf; +} + +} // namespace + +LogsWidget::LogsWidget() { + connections_.push_back(can->seekedTo.connect([this](double) { reset(); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { reset(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { reset(); })); +} + +void LogsWidget::setMessage(const MessageId &message_id) { + msg_id_ = message_id; + reset(); +} + +void LogsWidget::reset() { + sigs_.clear(); + if (auto dbc_msg = dbc()->msg(msg_id_)) sigs_ = dbc_msg->getSignals(); + messages_.clear(); + hex_colors_ = {}; + signals_cb_ = comp_box_ = 0; + value_edit_.clear(); + value_edit_modified_ = false; + export_btn_enabled_ = false; + selected_row_ = selected_col_ = -1; + setFilter(0, "", nullptr); +} + +void LogsWidget::setFilter(int sig_idx, const std::string &value, std::function cmp) { + filter_sig_idx_ = sig_idx; + filter_value_ = utils::toDouble(value); + filter_cmp_ = value.empty() ? nullptr : cmp; + load(true); +} + +void LogsWidget::load(bool clear) { + if (clear && !messages_.empty()) { + messages_.clear(); + selected_row_ = selected_col_ = -1; + } + const uint64_t current_time = can->toMonoTime(can->lastMessage(msg_id_).ts) + 1; + fetch(messages_.begin(), current_time, messages_.empty() ? 0 : messages_.front().mono_time); +} + +bool LogsWidget::canFetchMore() const { + const auto &events = can->events(msg_id_); + return !events.empty() && !messages_.empty() && messages_.back().mono_time > events.front()->mono_time; +} + +void LogsWidget::fetch(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time) { + const auto &events = can->events(msg_id_); + auto first = std::upper_bound(events.rbegin(), events.rend(), from_time, [](uint64_t ts, auto e) { return ts > e->mono_time; }); + + std::vector msgs; + std::vector values(sigs_.size()); + msgs.reserve(BATCH_SIZE); + for (; first != events.rend() && (*first)->mono_time > min_time; ++first) { + const CanEvent *e = *first; + for (int i = 0; i < sigs_.size(); ++i) { + sigs_[i]->getValue(e->dat, e->size, &values[i]); + } + if (!filter_cmp_ || filter_cmp_(values[filter_sig_idx_], filter_value_)) { + msgs.emplace_back(Message{e->mono_time, values, {e->dat, e->dat + e->size}}); + if (msgs.size() >= BATCH_SIZE && min_time == 0) break; + } + } + if (msgs.empty()) return; + + if (hexMode() && (min_time > 0 || messages_.empty())) { + const auto freq = can->lastMessage(msg_id_).freq; + const std::vector no_mask; + for (auto &m : msgs) { + hex_colors_.compute(msg_id_, m.data.data(), m.data.size(), m.mono_time / (double)1e9, can->getSpeed(), no_mask, freq); + m.colors = hex_colors_.colors; + } + } + const int pos = std::distance(messages_.begin(), insert_pos); + messages_.insert(insert_pos, std::move_iterator(msgs.begin()), std::move_iterator(msgs.end())); + export_btn_enabled_ = true; + // the selection follows the message it was made on + if (selected_row_ >= pos) selected_row_ += msgs.size(); +} + +void LogsWidget::filterChanged() { + if (value_edit_.empty() && !value_edit_modified_) return; + + std::function cmp = nullptr; + switch (comp_box_) { + case 0: cmp = std::greater{}; break; + case 1: cmp = std::equal_to{}; break; + case 2: cmp = [](double l, double r) { return l != r; }; break; + case 3: cmp = std::less{}; break; + } + setFilter(signals_cb_, value_edit_, cmp); +} + +void LogsWidget::exportToCSV() { + std::string dir = settings.last_dir + "/" + can->routeName() + "_" + msgName(msg_id_) + ".csv"; + FileDialog::getSaveFileName("Export " + msgName(msg_id_) + " to CSV file", dir, ".csv", [this](const std::string &fn) { + if (!fn.empty()) { + hexMode() ? utils::exportToCSV(fn, msg_id_) : utils::exportSignalsToCSV(fn, msg_id_); + } + }); +} + +void LogsWidget::draw() { + const ImGuiStyle &style = ImGui::GetStyle(); + + // toolbar: the export button is right aligned and never clipped, the value input shrinks first + const float export_w = ImGui::CalcTextSize(icon::FILETYPE_CSV).x + style.FramePadding.x * 2; + if (!sigs_.empty()) { + const float clear_w = value_edit_.empty() ? 0.0f : ImGui::CalcTextSize(icon::X).x + style.FramePadding.x * 2; + const float fixed = DISPLAY_TYPE_WIDTH + SIGNALS_WIDTH + COMPARE_WIDTH + clear_w + style.ItemSpacing.x * 4 + export_w; + const float value_w = std::clamp(ImGui::GetContentRegionAvail().x - fixed, 30.0f, 120.0f); + + ImGui::SetNextItemWidth(DISPLAY_TYPE_WIDTH); + if (ImGui::Combo("##display_type", &display_type_cb_, "Signal\0Hex\0")) { + hex_mode_ = display_type_cb_; + reset(); + } + ImGui::SetItemTooltip("Display signal value or raw hex value"); + ImGui::SameLine(); + std::string sig_items; + for (auto s : sigs_) { + sig_items += s->name; + sig_items += '\0'; + } + sig_items += '\0'; + ImGui::SetNextItemWidth(SIGNALS_WIDTH); + if (ImGui::Combo("##signals", &signals_cb_, sig_items.c_str())) filterChanged(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(COMPARE_WIDTH); + if (ImGui::Combo("##comp", &comp_box_, ">\0=\0!=\0<\0")) filterChanged(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(value_w); + if (clearableInput("##value", &value_edit_, "", doubleValidator)) { + value_edit_modified_ = true; // clearing the field still counts as modified + filterChanged(); + } + } + alignRight(export_w); + ImGui::BeginDisabled(!export_btn_enabled_); + if (ImGui::Button(icon::FILETYPE_CSV)) exportToCSV(); + ImGui::EndDisabled(); + disabledItemTooltip("Export to CSV file..."); + + ImGui::Separator(); + drawTable(); +} + +std::string LogsWidget::headerText(int column) const { + if (column == 0) return "Time"; + if (hexMode()) return "Data"; + std::string text = sigs_[column - 1]->name; + if (!sigs_[column - 1]->unit.empty()) text += " (" + sigs_[column - 1]->unit + ")"; + std::replace(text.begin(), text.end(), '_', ' '); + return text; +} + +ImVec2 LogsWidget::headerSize(int column, float viewport_width) const { + const ImVec2 time_text_size = ImGui::CalcTextSize("000000.000"); + const ImVec2 time_col_size(time_text_size.x + 10, time_text_size.y + 6); + if (column == 0) return time_col_size; + const int default_size = std::max(100, (int)((viewport_width - time_col_size.x) / (columnCount() - 1))); + const ImVec2 rect = ImGui::CalcTextSize(headerText(column).c_str(), nullptr, false, default_size); + return ImVec2{std::max(rect.x + 10, (float)default_size), rect.y + 6}; +} + +void LogsWidget::drawHeaderCell(ImDrawList *dl, const ImRect &rect, int column) const { + if (column > 0 && !hexMode()) { + CabanaColor bg = sigs_[column - 1]->color; + bg.a = 128; + dl->AddRectFilled(rect.Min, rect.Max, toImU32(bg)); + } + const std::string text = headerText(column); + const ImU32 color = isDarkTheme() ? toImU32(DarkTheme::bright_text) : ImGui::GetColorU32(ImGuiCol_Text); + // right aligned and word wrapped, one line at a time + const ImRect r(rect.Min.x + 5, rect.Min.y + 3, rect.Max.x - 5, rect.Max.y - 3); + ImFont *font = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + const float wrap_width = std::max(1.0f, r.GetWidth()); + const char *s = text.c_str(); + const char *end = s + text.size(); + float y = r.Min.y; + dl->PushClipRect(rect.Min, rect.Max, true); + while (s < end) { + const char *line_end = font->CalcWordWrapPosition(font_size, s, end, wrap_width); + if (line_end == s) line_end = s + 1; + const float w = ImGui::CalcTextSize(s, line_end).x; + dl->AddText(font, font_size, ImVec2(r.Max.x - w, y), color, s, line_end); + y += ImGui::GetTextLineHeight(); + s = line_end; + while (s < end && ImCharIsBlankA(*s)) s++; + if (s < end && *s == '\n') s++; + } + dl->PopClipRect(); +} + +void LogsWidget::drawTable() { + const ImGuiStyle &style = ImGui::GetStyle(); + const int cols = columnCount(); + // the header viewport excludes the table's cell padding and the vertical scrollbar (the latter is one + // frame behind, it is only known once shown) + const float header_width = ImGui::GetContentRegionAvail().x - style.CellPadding.x * 2 * cols - + (vscrollbar_visible_ ? style.ScrollbarSize : 0.0f); + + std::vector sizes(cols); + float header_height = 0; + for (int i = 0; i < cols; ++i) { + sizes[i] = headerSize(i, header_width); + header_height = std::max(header_height, sizes[i].y); + } + if (hexMode() && !messages_.empty()) { + sizes[1].x = std::max(sizes[1].x, bytesCellSize(messages_.front().data.size(), false).x); + } + const float row_height = bytesCellSize(8, false).y; + + // fixed section sizes and a horizontal scrollbar, no alternating row colors; the grid is drawn between + // rows and columns + ImGuiTableFlags flags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_ScrollX | ImGuiTableFlags_BordersInner | + ImGuiTableFlags_SizingFixedFit; + // an empty viewport draws no grid + if (messages_.empty()) flags &= ~ImGuiTableFlags_BordersInnerV; + // the sum of the fixed section sizes, so the columns keep their size and the table scrolls + float inner_width = 0; + for (int i = 0; i < cols; ++i) inner_width += sizes[i].x + style.CellPadding.x * 2; + + bool fetch_more = false; + if (ImGui::BeginTable("logs", cols, flags, ImVec2(0, 0), inner_width)) { + ImGui::TableSetupScrollFreeze(0, 1); + for (int i = 0; i < cols; ++i) { + ImGui::TableSetupColumn(headerText(i).c_str(), ImGuiTableColumnFlags_WidthFixed, sizes[i].x); + } + ImGuiTable *table = ImGui::GetCurrentTable(); + ImDrawList *painter = ImGui::GetWindowDrawList(); + + ImGui::TableNextRow(ImGuiTableRowFlags_Headers, header_height); + for (int i = 0; i < cols; ++i) { + if (!ImGui::TableSetColumnIndex(i)) continue; + drawHeaderCell(painter, ImGui::TableGetCellBgRect(table, i), i); + ImGui::Dummy(ImVec2(0, header_height - style.CellPadding.y * 2)); + } + + ImGuiListClipper clipper; + clipper.Begin(messages_.size(), row_height); + while (clipper.Step()) { + for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) { + const auto &m = messages_[row]; + ImGui::TableNextRow(0, row_height); + // rows are prepended while the stream plays, so the id is the message, not the row index + ImGui::PushID((void *)(uintptr_t)m.mono_time); + for (int col = 0; col < cols; ++col) { + if (!ImGui::TableSetColumnIndex(col)) continue; + // cells are selected, not rows; there is no hover highlight, only the selection background + const bool cell_selected = selected_row_ == row && selected_col_ == col; + ImGui::PushID(col); + if (viewSelectable("##cell", cell_selected, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, row_height - style.CellPadding.y * 2))) { + selected_row_ = row; + selected_col_ = col; + } + ImGui::PopID(); + const ImRect rect = ImGui::TableGetCellBgRect(table, col); + if (col == 0) { + drawTextCell(painter, rect, formatTime(m.mono_time), cell_selected, false); + } else if (hexMode()) { + drawBytesCell(painter, rect, m.data, &m.colors, cell_selected, false, false); + } else { + drawTextCell(painter, rect, sigs_[col - 1]->formatValue(m.sig_values[col - 1], false), cell_selected, false); + } + } + ImGui::PopID(); + } + // fetch more when the last row is visible or the scrollbar is at its maximum + if (clipper.DisplayEnd >= (int)messages_.size()) fetch_more = true; + } + if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) fetch_more = true; + vscrollbar_visible_ = table->InnerWindow->ScrollbarY; + ImGui::EndTable(); + } + if (fetch_more && canFetchMore()) fetch(messages_.end(), messages_.back().mono_time, 0); +} diff --git a/openpilot/tools/cabana/ui/widgets/historylog.h b/openpilot/tools/cabana/ui/widgets/historylog.h new file mode 100644 index 0000000000..d8e68a4829 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/historylog.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" + +// the Logs tab: the messages of one id, newest first, as signal values or hex bytes +class LogsWidget { +public: + LogsWidget(); + void setMessage(const MessageId &message_id); + void updateState() { load(false); } // appends what arrived since the last call + void onShown() { load(true); } // reloads the log when the Logs tab becomes visible + void draw(); + +private: + struct Message { + uint64_t mono_time = 0; + std::vector sig_values; + std::vector data; + std::vector colors; + }; + + bool hexMode() const { return sigs_.empty() || hex_mode_; } + int columnCount() const { return hexMode() ? 2 : (int)sigs_.size() + 1; } + void reset(); + void setFilter(int sig_idx, const std::string &value, std::function cmp); + void load(bool clear); + bool canFetchMore() const; + void fetch(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time); + void filterChanged(); + void exportToCSV(); + void drawTable(); + std::string headerText(int column) const; + ImVec2 headerSize(int column, float viewport_width) const; + void drawHeaderCell(ImDrawList *dl, const ImRect &rect, int column) const; + + MessageId msg_id_; + std::vector sigs_; + std::deque messages_; + CanData hex_colors_; + bool hex_mode_ = false; + int filter_sig_idx_ = -1; + double filter_value_ = 0; + std::function filter_cmp_; + int signals_cb_ = 0, comp_box_ = 0, display_type_cb_ = 0; // current combo box indices + std::string value_edit_; + bool value_edit_modified_ = false; + bool export_btn_enabled_ = false; + int selected_row_ = -1, selected_col_ = -1; + bool vscrollbar_visible_ = false; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.cc b/openpilot/tools/cabana/ui/widgets/messagebytes.cc new file mode 100644 index 0000000000..9052949eef --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.cc @@ -0,0 +1,60 @@ +#include "tools/cabana/ui/widgets/messagebytes.h" + +#include +#include + +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +ImVec2 byteCellSize() { + pushMonoFont(); + // the width of "00 " by the line height (ascent + descent + 1), not the font size + const ImFontBaked *baked = ImGui::GetFontBaked(); + const ImVec2 size(ImGui::CalcTextSize("00 ").x, std::ceil(baked->Ascent) - std::floor(baked->Descent) + 1 + 2); + popMonoFont(); + return size; +} + +ImVec2 bytesCellSize(int n, bool multiple_lines) { + const ImVec2 byte_size = byteCellSize(); + const int rows = multiple_lines ? std::max(1, (n + 7) / 8) : 1; + const int columns = multiple_lines ? std::min(n, 8) : n; + const ImVec2 margin = ImGui::GetStyle().CellPadding; // the margin is one more than the table's cell padding + return {columns * byte_size.x + (margin.x + 1) * 2, rows * byte_size.y + (margin.y + 1) * 2}; +} + +ImU32 cellTextColor(bool selected, bool inactive) { + if (selected) return inactive ? withAlpha(highlightedTextColor(), 100) : highlightedTextColor(); + return ImGui::GetColorU32(inactive ? ImGuiCol_TextDisabled : ImGuiCol_Text); +} + +void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive) { + drawElidedText(dl, rect, text, cellTextColor(selected, inactive)); +} + +void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vector &bytes, const std::vector *colors, + bool selected, bool inactive, bool multiple_lines) { + const ImU32 text_pen = cellTextColor(selected, inactive); + const ImVec2 byte_size = byteCellSize(); + pushMonoFont(); + ImFont *font = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + for (int i = 0; i < (int)bytes.size(); ++i) { + const int row = multiple_lines ? i / 8 : 0; + const int column = multiple_lines ? i % 8 : i; + const ImVec2 min(rect.Min.x + column * byte_size.x, rect.Min.y + row * byte_size.y); + const ImRect r(min, ImVec2(min.x + byte_size.x, min.y + byte_size.y)); + + // a colored unselected byte keeps text_pen, like the other cells of the row + ImU32 pen = text_pen; + if (colors && i < (int)colors->size() && (*colors)[i].alpha() > 0) { + if (selected) { + pen = ImGui::GetColorU32(ImGuiCol_Text); + dl->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); + } + dl->AddRectFilled(r.Min, r.Max, toImU32((*colors)[i])); + } + drawText(dl, r, utils::hexByte(bytes[i]), pen, font, font_size); + } + popMonoFont(); +} diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.h b/openpilot/tools/cabana/ui/widgets/messagebytes.h new file mode 100644 index 0000000000..7c7aabc984 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/core/color.h" + +// the cells of the messages and history log tables. Only valid inside a frame: the sizes come from the +// mono font. + +ImVec2 byteCellSize(); // one "00 " cell +ImVec2 bytesCellSize(int n, bool multiple_lines); // a cell of n bytes, the table's cell padding included +ImU32 cellTextColor(bool selected, bool inactive); // inactive rows gray the text and fade the highlighted text + +void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive); +void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vector &bytes, const std::vector *colors, + bool selected, bool inactive, bool multiple_lines); diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc new file mode 100644 index 0000000000..102960b9a9 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -0,0 +1,512 @@ +#include "tools/cabana/ui/widgets/messageswidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui_internal.h" +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/widgets/messagebytes.h" +#include "tools/cabana/utils/strings.h" + +namespace { + +const char *COLUMN_TITLES[MessageList::COLUMN_COUNT] = {"Name", "Bus", "ID", "Node", "Freq", "Count", "Bytes"}; +constexpr float DEFAULT_SECTION_SIZE = 100.0f; + +// surrounding whitespace is ignored; no sign, no 0x prefix +unsigned int toUInt(const std::string &s, bool *ok, int base) { + const char *b = s.data(), *e = b + s.size(); + while (b < e && std::isspace((unsigned char)*b)) ++b; + while (e > b && std::isspace((unsigned char)e[-1])) --e; + unsigned int v = 0; + auto [p, ec] = std::from_chars(b, e, v, base); + *ok = b < e && p == e && ec == std::errc(); + return *ok ? v : 0; +} + +bool parseRange(const std::string &filter, uint32_t value, int base = 10) { + // parse the filter string into a range: "1" -> {1, 1}, "1-3" -> {1, 3}, "1-" -> {1, inf} + unsigned int min = std::numeric_limits::min(); + unsigned int max = std::numeric_limits::max(); + auto s = utils::split(filter, '-'); + bool ok = s.size() >= 1 && s.size() <= 2; + if (ok && !s[0].empty()) min = toUInt(s[0], &ok, base); + if (ok && s.size() == 1) { + max = min; + } else if (ok && s.size() == 2 && !s[1].empty()) { + max = toUInt(s[1], &ok, base); + } + return ok && value >= min && value <= max; +} + +// imgui draws an up arrow for ImGuiSortDirection_Ascending, cabana wants a down pointing one. Feed imgui +// the opposite direction and flip it back before it reaches the list. +inline ImGuiSortDirection flipSortDirection(ImGuiSortDirection dir) { + return dir == ImGuiSortDirection_Ascending ? ImGuiSortDirection_Descending : ImGuiSortDirection_Ascending; +} + +std::string formatFreq(float freq) { + if (freq <= 0) return "--"; + char buf[32]; + snprintf(buf, sizeof(buf), freq >= 0.95 ? "%.0f" : "%.2f", freq >= 0.95 ? std::nearbyint(freq) : freq); + return buf; +} + +// the text of a cell; the DATA cell paints the bytes itself +std::string cellText(const MessageList::Item &item, int column) { + const bool seen = item.id.source != INVALID_SOURCE; + switch (column) { + case MessageList::NAME: return item.name; + case MessageList::SOURCE: return seen ? std::to_string(item.id.source) : "N/A"; + case MessageList::ADDRESS: return utils::toHexString(item.id.address); + case MessageList::NODE: return item.node; + case MessageList::FREQ: return seen ? formatFreq(can->lastMessage(item.id).freq) : "N/A"; + case MessageList::COUNT: return seen ? std::to_string(can->lastMessage(item.id).count) : "N/A"; + case MessageList::DATA: return seen ? "" : "N/A"; + } + return {}; +} + +} // namespace + +// MessageList + +MessageList::MessageList() { + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool has_new_ids) { msgsReceived(msgs, has_new_ids); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { dbcModified(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { dbcModified(); })); +} + +void MessageList::setFilters(const std::map &filters) { + filters_ = filters; + filterAndSort(); +} + +void MessageList::showInactiveMessages(bool show) { + show_inactive_messages = show; + filterAndSort(); +} + +void MessageList::dbcModified() { + dbc_messages_.clear(); + for (const auto &[_, m] : dbc()->getMessages(-1)) { + dbc_messages_.insert(MessageId{.source = INVALID_SOURCE, .address = m.address}); + } + filterAndSort(); +} + +void MessageList::sortItems(std::vector &list) { + auto compare = [this](const auto &l, const auto &r) { + switch (sort_column_) { + case NAME: return std::tie(l.name, l.id) < std::tie(r.name, r.id); + case SOURCE: return std::tie(l.id.source, l.id.address) < std::tie(r.id.source, r.id.address); + case ADDRESS: return std::tie(l.id.address, l.id.source) < std::tie(r.id.address, r.id.source); + case NODE: return std::tie(l.node, l.id) < std::tie(r.node, r.id); + case FREQ: return std::tie(can->lastMessage(l.id).freq, l.id) < std::tie(can->lastMessage(r.id).freq, r.id); + case COUNT: return std::tie(can->lastMessage(l.id).count, l.id) < std::tie(can->lastMessage(r.id).count, r.id); + default: return false; + } + }; + + if (sort_order_ == ImGuiSortDirection_Descending) + std::stable_sort(list.rbegin(), list.rend(), compare); + else + std::stable_sort(list.begin(), list.end(), compare); +} + +bool MessageList::match(const Item &item) { + 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 std::string &txt = it->second; + switch (it->first) { + case NAME: { + match = utils::containsCI(item.name, txt); + 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 utils::containsCI(s->name, txt); }); + } + break; + } + case SOURCE: + match = parseRange(txt, item.id.source); + break; + case ADDRESS: + match = utils::containsCI(utils::toHexString(item.id.address), txt); + match = match || parseRange(txt, item.id.address, 16); + break; + case NODE: + match = utils::containsCI(item.node, txt); + break; + case FREQ: + match = parseRange(txt, data.freq); + break; + case COUNT: + match = parseRange(txt, data.count); + break; + case DATA: + match = utils::containsCI(utils::toHex(data.dat), txt); + break; + } + } + return match; +} + +bool MessageList::filterAndSort() { + // merge CAN and DBC messages + std::vector all_messages; + all_messages.reserve(can->lastMessages().size() + dbc_messages_.size()); + auto dbc_msgs = dbc_messages_; + for (const auto &[id, m] : can->lastMessages()) { + all_messages.push_back(id); + dbc_msgs.erase(MessageId{.source = INVALID_SOURCE, .address = id.address}); + } + all_messages.insert(all_messages.end(), dbc_msgs.begin(), dbc_msgs.end()); + + std::vector new_items; + new_items.reserve(all_messages.size()); + for (const auto &id : all_messages) { + 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 : std::string()}; + if (match(item)) new_items.emplace_back(item); + } + } + sortItems(new_items); + + if (items != new_items) { + items = std::move(new_items); + changed(); + return true; + } + return false; +} + +void MessageList::msgsReceived(const std::set *new_msgs, bool has_new_ids) { + if (has_new_ids || ((filters_.count(FREQ) || filters_.count(COUNT) || filters_.count(DATA)) && + ++sort_threshold_ == STREAM_UPDATE_FPS)) { + sort_threshold_ = 0; + filterAndSort(); + } +} + +void MessageList::sort(int column, ImGuiSortDirection order) { + if (column != DATA) { + sort_column_ = column; + sort_order_ = order; + filterAndSort(); + } +} + +// MessagesWidget + +MessagesWidget::MessagesWidget() { + std::iota(display_order_.begin(), display_order_.end(), 0); + list_.sort(MessageList::NAME, ImGuiSortDirection_Ascending); + + connections_.push_back(list_.changed.connect([this]() { + current_row_ = -1; // the rows moved + if (current_msg_id_) selectMessage(*current_msg_id_); + updateBytesSectionSize(); + updateTitle(); + })); + + suppressHighlighted(); +} + +std::string MessagesWidget::whatsThis() const { + return R"( + Message View
+ Byte color
+ ■ constant changing
+ ■ increasing
+ ■ decreasing
+ Shortcuts
+ Horizontal Scrolling:  shift+wheel  + )"; +} + +void MessagesWidget::drawToolBar() { + ImGui::Dummy(ImVec2(0, std::max(0.0f, 9 - ImGui::GetStyle().ItemSpacing.y))); + if (ImGui::Button("Suppress Highlighted")) suppressHighlighted(true); + ImGui::SameLine(); + ImGui::BeginDisabled(!suppress_clear_enabled_); + const std::string clear_label = suppress_clear_text_ + "##suppress_clear"; + if (ImGui::Button(clear_label.c_str())) suppressHighlighted(false); + ImGui::EndDisabled(); + disabledItemTooltip("Clear suppressed"); + + const ImGuiStyle &style = ImGui::GetStyle(); + const float checkbox_width = ImGui::CalcTextSize("Suppress Signals").x + ImGui::GetFrameHeight() + style.ItemInnerSpacing.x; + const float view_button_width = ImGui::CalcTextSize(icon::THREE_DOTS).x + style.FramePadding.x * 2; + alignRight(checkbox_width + style.ItemSpacing.x + view_button_width); + + bool suppress_defined_signals = settings.suppress_defined_signals; + if (checkBox("Suppress Signals", &suppress_defined_signals)) can->suppressDefinedSignals(suppress_defined_signals); + ImGui::SetItemTooltip("Suppress defined signals"); + ImGui::SameLine(); + + if (toolButton("view_btn", icon::THREE_DOTS, "View...")) ImGui::OpenPopup("menu"); +} + +void MessagesWidget::updateTitle() { + auto stats = std::accumulate( + list_.items.begin(), list_.items.end(), std::pair(), + [](const auto &pair, const auto &item) { + auto m = dbc()->msg(item.id); + return m ? std::make_pair(pair.first + 1, pair.second + m->sigs.size()) : pair; + }); + char buf[128]; + snprintf(buf, sizeof(buf), "%zu Messages (%zu DBC Messages, %zu Signals)", list_.items.size(), stats.first, stats.second); + title_ = buf; +} + +void MessagesWidget::selectMessage(const MessageId &msg_id) { + auto it = std::find_if(list_.items.cbegin(), list_.items.cend(), [&msg_id](auto &item) { return item.id == msg_id; }); + if (it != list_.items.cend()) setCurrentRow(std::distance(list_.items.cbegin(), it)); +} + +void MessagesWidget::setCurrentRow(int row) { + if (row < 0 || row >= (int)list_.items.size()) return; + current_row_ = row; + scroll_to_current_ = true; + const auto &id = list_.items[row].id; + if (!current_msg_id_ || id != *current_msg_id_) { + current_msg_id_ = id; + msgSelectionChanged(*current_msg_id_); + } +} + +void MessagesWidget::suppressHighlighted(bool from_suppress_add) { + int n = from_suppress_add ? can->suppressHighlighted() : (can->clearSuppressed(), 0); + suppress_clear_text_ = n > 0 ? "Clear (" + std::to_string(n) + ")" : "Clear"; + suppress_clear_enabled_ = n > 0; +} + +void MessagesWidget::drawContextMenu() { + if (!ImGui::BeginPopup("menu")) return; + for (int i = 0; i < MessageList::COLUMN_COUNT; ++i) { + const int column = display_order_[i]; + // can't hide the name column + if (ImGui::MenuItem(COLUMN_TITLES[column], nullptr, !hidden_[column], column > 0)) { + pending_hidden_.emplace_back(column, !hidden_[column]); + } + } + ImGui::Separator(); + if (ImGui::MenuItem("Multi-Line bytes", nullptr, settings.multiple_lines_hex)) { + setMultiLineBytes(!settings.multiple_lines_hex); + } + if (ImGui::MenuItem("Show inactive messages", nullptr, list_.show_inactive_messages)) { + list_.showInactiveMessages(!list_.show_inactive_messages); + } + ImGui::EndPopup(); +} + +void MessagesWidget::setMultiLineBytes(bool multi) { + settings.multiple_lines_hex = multi; + updateBytesSectionSize(); +} + +void MessagesWidget::updateBytesSectionSize() { + int max_bytes = 8; + if (!settings.multiple_lines_hex) { + for (const auto &[_, m] : can->lastMessages()) { + max_bytes = std::max(max_bytes, m.dat.size()); + } + } + bytes_section_bytes_ = max_bytes; +} + +void MessagesWidget::draw() { + drawToolBar(); + drawTable(); + if (std::exchange(header_menu_requested_, false)) ImGui::OpenPopup("menu"); // at the mouse position + drawContextMenu(); +} + +void MessagesWidget::handleKeys() { + if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows) || ImGui::IsAnyItemActive()) return; + const int last = (int)list_.items.size() - 1; + if (last < 0) return; + if (ImGui::IsKeyPressed(ImGuiKey_UpArrow) && current_row_ > 0) { + setCurrentRow(current_row_ - 1); + } else if (ImGui::IsKeyPressed(ImGuiKey_DownArrow) && current_row_ < last) { + setCurrentRow(current_row_ + 1); + } else if (ImGui::IsKeyPressed(ImGuiKey_Home)) { + setCurrentRow(0); + } else if (ImGui::IsKeyPressed(ImGuiKey_End)) { + setCurrentRow(last); + } else if (ImGui::IsKeyPressed(ImGuiKey_PageUp)) { + setCurrentRow(std::max(current_row_ - visible_rows_, 0)); + } else if (ImGui::IsKeyPressed(ImGuiKey_PageDown)) { + setCurrentRow(std::min(current_row_ + visible_rows_, last)); + } +} + +void MessagesWidget::drawTable() { + handleKeys(); + const bool multiple_lines = settings.multiple_lines_hex; + + const ImGuiTableFlags flags = ImGuiTableFlags_Sortable | ImGuiTableFlags_Resizable | ImGuiTableFlags_Reorderable | + ImGuiTableFlags_ScrollX | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Borders | + ImGuiTableFlags_Hideable; + // with ScrollX a stretch column needs an explicit inner width + const float bytes_width = bytesCellSize(bytes_section_bytes_, multiple_lines).x; + const float avail_width = ImGui::GetContentRegionAvail().x - (has_scrollbar_y_ ? ImGui::GetStyle().ScrollbarSize : 0); + const float inner_width = std::max(avail_width, fixed_columns_width_ + bytes_width); + if (!ImGui::BeginTable("messages", MessageList::COLUMN_COUNT, flags, ImVec2(0, 0), inner_width)) return; + + // no frozen column: only the header and the filter row stay put + ImGui::TableSetupScrollFreeze(0, 2); + // the widget shows its own header menu; imgui's default one is never drawn, so clear the flag + // TableHeader() leaves behind (it would keep the column header highlighted forever) + ImGuiTable *table = ImGui::GetCurrentTable(); + table->DisableDefaultContextMenu = true; + table->IsContextPopupOpen = false; + for (int i = 0; i < MessageList::COLUMN_COUNT; ++i) { + // with the flipped direction the first click on a section sorts ascending + ImGuiTableColumnFlags column_flags = ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_PreferSortDescending; + float width = DEFAULT_SECTION_SIZE; + if (i == MessageList::NAME) { + column_flags |= ImGuiTableColumnFlags_DefaultSort | ImGuiTableColumnFlags_NoHide; + } else if (i == MessageList::DATA) { + column_flags = ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_NoSort | ImGuiTableColumnFlags_NoResize; + width = 0; + } + ImGui::TableSetupColumn(COLUMN_TITLES[i], column_flags, width); + } + for (const auto &[column, hide] : pending_hidden_) ImGui::TableSetColumnEnabled(column, !hide); + pending_hidden_.clear(); + + if (ImGuiTableSortSpecs *specs = ImGui::TableGetSortSpecs(); specs && specs->SpecsDirty) { + if (specs->SpecsCount > 0) list_.sort(specs->Specs[0].ColumnIndex, flipSortDirection(specs->Specs[0].SortDirection)); + specs->SpecsDirty = false; + if (current_row_ >= 0) scroll_to_current_ = true; // keep the current row visible + } + + drawHeader(); + + const int rows = list_.items.size(); + if (!multiple_lines) { + ImGuiListClipper clipper; + clipper.Begin(rows); + if (scroll_to_current_ && current_row_ >= 0) clipper.IncludeItemByIndex(current_row_); + while (clipper.Step()) { + for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) drawRow(row); + } + } else { + // non-uniform row heights: no clipper + for (int row = 0; row < rows; ++row) drawRow(row); + } + + // for the next frame: the stretch inner width and the page size of handleKeys + const ImGuiTableColumn &data_column = table->Columns[MessageList::DATA]; + fixed_columns_width_ = data_column.IsEnabled ? table->ColumnsGivenWidth - data_column.WidthGiven : 0; + has_scrollbar_y_ = table->InnerWindow->ScrollbarY; + visible_rows_ = std::max(1, (int)(table->InnerWindow->InnerRect.GetHeight() / bytesCellSize(0, multiple_lines).y) - 2); + ImGui::EndTable(); +} + +void MessagesWidget::drawHeader() { + if (tableHeadersRow() >= 0) header_menu_requested_ = true; + // record the visual order and the visibility of the sections for the menu + ImGuiTable *table = ImGui::GetCurrentTable(); + for (int i = 0; i < MessageList::COLUMN_COUNT; i++) { + display_order_[i] = table->DisplayOrderToIndex[i]; + hidden_[i] = !(ImGui::TableGetColumnFlags(i) & ImGuiTableColumnFlags_IsEnabled); + } + + // the filter editors under the header + const float clear_width = ImGui::CalcTextSize(icon::X).x + ImGui::GetStyle().FramePadding.x * 2; + ImGui::TableNextRow(); + for (int i = 0; i < MessageList::COLUMN_COUNT; i++) { + if (!ImGui::TableSetColumnIndex(i)) continue; + ImGui::PushID(i); + ImGui::SetNextItemWidth(filters_[i].empty() ? -FLT_MIN : std::max(1.0f, ImGui::GetContentRegionAvail().x - clear_width)); + const std::string placeholder = std::string("Filter ") + COLUMN_TITLES[i]; + if (clearableInput("##filter", &filters_[i], placeholder.c_str())) { + std::map filters; + for (int c = 0; c < MessageList::COLUMN_COUNT; ++c) { + if (!filters_[c].empty()) filters[c] = filters_[c]; + } + list_.setFilters(filters); + } + ImGui::PopID(); + } +} + +void MessagesWidget::drawRow(int row) { + const auto &item = list_.items[row]; + const bool selected = row == current_row_; + const bool inactive = !can->isMessageActive(item.id); + const auto &m = can->lastMessage(item.id); + const bool seen = item.id.source != INVALID_SOURCE; + const bool multiple_lines = settings.multiple_lines_hex; + const float row_height = bytesCellSize(seen ? m.dat.size() : 0, multiple_lines).y - ImGui::GetStyle().CellPadding.y * 2; + ImGui::TableNextRow(); + ImGui::PushID(row); + + bool row_item_submitted = false; + for (int column = 0; column < MessageList::COLUMN_COUNT; ++column) { + if (!ImGui::TableSetColumnIndex(column)) continue; + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x; + const ImRect rect(pos, ImVec2(pos.x + width, pos.y + row_height)); + + const bool row_item = !row_item_submitted; + if (row_item) { + // the row selection spans all columns; submit it in the first visible column so that it is not + // clipped away when the table is scrolled horizontally + row_item_submitted = true; + // rows select on press + if (viewSelectable("##row", selected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_SelectOnClick, ImVec2(0, row_height))) { + setCurrentRow(row); + } + if (selected && scroll_to_current_) { + // only scroll when the row is outside the viewport, and only far enough + const ImGuiWindow *inner = ImGui::GetCurrentTable()->InnerWindow; + const float view_top = inner->InnerClipRect.Min.y + inner->DecoInnerSizeY1; + const float view_bottom = inner->InnerClipRect.Max.y; + if (ImGui::GetItemRectMin().y < view_top) { + ImGui::SetScrollHereY(0.0f); + } else if (ImGui::GetItemRectMax().y > view_bottom) { + ImGui::SetScrollHereY(1.0f); + } + scroll_to_current_ = false; + } + // the tooltip belongs to the name, so only show it while the mouse is over the Name column + const ImGuiTableColumn &name_col = ImGui::GetCurrentTable()->Columns[MessageList::NAME]; + const float mouse_x = ImGui::GetIO().MousePos.x; + if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip) && mouse_x >= name_col.MinX && mouse_x < name_col.MaxX) { + auto msg = dbc()->msg(item.id); + ImGui::BeginTooltip(); + ImGui::TextUnformatted(item.name.c_str()); + if (msg && !msg->comment.empty()) ImGui::TextDisabled("%s", msg->comment.c_str()); + ImGui::EndTooltip(); + } + } + + if (column == MessageList::DATA && seen) { + drawBytesCell(ImGui::GetWindowDrawList(), rect, m.dat, &m.colors, selected, inactive, multiple_lines); + } else { + drawTextCell(ImGui::GetWindowDrawList(), rect, cellText(item, column), selected, inactive); + } + // the Selectable already sized its cell + if (!row_item) ImGui::Dummy(ImVec2(width, row_height)); + } + + ImGui::PopID(); +} diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.h b/openpilot/tools/cabana/ui/widgets/messageswidget.h new file mode 100644 index 0000000000..abfd43fb50 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.h @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" + +// the rows of the messages table: every CAN message seen plus the DBC messages not seen yet, filtered +// and sorted. `changed` fires when the rows differ from the last time. +class MessageList { +public: + enum Column { NAME = 0, SOURCE, ADDRESS, NODE, FREQ, COUNT, DATA, COLUMN_COUNT }; + + struct Item { + MessageId id; + std::string name; + std::string node; + bool operator==(const Item &other) const { return id == other.id && name == other.name && node == other.node; } + }; + + MessageList(); + void sort(int column, ImGuiSortDirection order); + void setFilters(const std::map &filters); + void showInactiveMessages(bool show); + bool filterAndSort(); + + std::vector items; + bool show_inactive_messages = true; + Observable<> changed; + +private: + void msgsReceived(const std::set *new_msgs, bool has_new_ids); + void dbcModified(); + void sortItems(std::vector &list); + bool match(const Item &item); + + std::map filters_; + std::set dbc_messages_; + int sort_column_ = NAME; + ImGuiSortDirection sort_order_ = ImGuiSortDirection_Ascending; + int sort_threshold_ = 0; + Connections connections_; +}; + +class MessagesWidget { +public: + MessagesWidget(); + void draw(); // content only; MainWindow does ImGui::Begin/End with the dock title + void selectMessage(const MessageId &message_id); + void suppressHighlighted(bool from_suppress_add = false); + const std::string &title() const { return title_; } + std::string whatsThis() const; + + Observable msgSelectionChanged; + +private: + void drawToolBar(); + void drawTable(); + void drawHeader(); // the header row and the filter editors row, inside the table + void drawRow(int row); + void drawContextMenu(); + void handleKeys(); // up/down move the current row + void setCurrentRow(int row); // scrolls to the row + void updateBytesSectionSize(); + void updateTitle(); + void setMultiLineBytes(bool multi); + + MessageList list_; + std::optional current_msg_id_; + int current_row_ = -1; + bool scroll_to_current_ = false; + int bytes_section_bytes_ = 8; // the minimum width of the stretched DATA section + float fixed_columns_width_ = 0; // width of everything but the stretched DATA section + bool has_scrollbar_y_ = false; + int visible_rows_ = 1; + std::array filters_; + std::array hidden_ = {}; // mirror of the table's enabled columns, refreshed every frame + std::array display_order_; // visual index -> logical column + std::vector> pending_hidden_; // applied inside the table + bool header_menu_requested_ = false; + std::string suppress_clear_text_; + bool suppress_clear_enabled_ = false; + std::string title_ = "MESSAGES"; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc new file mode 100644 index 0000000000..feda7ce34d --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc @@ -0,0 +1,88 @@ +#include "tools/cabana/ui/widgets/scrollabletabbar.h" + +#include +#include +#include + +#include "imgui_internal.h" + +namespace { +float scrollButtonsWidth() { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::GetFrameHeight() * 2.0f + style.ItemInnerSpacing.x + style.ItemSpacing.x * 2.0f; +} + +void drawScrollButtons(ImGuiTabBar *tab_bar) { + const ImGuiStyle &style = ImGui::GetStyle(); + const float size = ImGui::GetFrameHeight(); + const float max_scroll = std::max(0.0f, tab_bar->WidthAllTabs - tab_bar->BarRect.GetWidth()); + const float start_x = tab_bar->BarRect.Max.x + style.ItemSpacing.x; + const ImVec2 backup_pos = ImGui::GetCursorScreenPos(); + + ImGui::PushItemFlag(ImGuiItemFlags_ButtonRepeat, true); + for (int i = 0; i < 2; ++i) { + const bool left = i == 0; + ImGui::SetCursorScreenPos(ImVec2(start_x + i * (size + style.ItemInnerSpacing.x), tab_bar->BarRect.Min.y)); + ImGui::BeginDisabled(left ? tab_bar->ScrollingTarget <= 0.0f : tab_bar->ScrollingTarget >= max_scroll); + if (ImGui::Button(left ? "###scroll_left" : "###scroll_right", ImVec2(size, size))) { + const float step = (left ? -4.0f : 4.0f) * ImGui::GetFontSize(); + tab_bar->ScrollingTarget = std::clamp(tab_bar->ScrollingTarget + step, 0.0f, max_scroll); + tab_bar->ScrollingAnim = tab_bar->ScrollingTarget; + } + // the icon font glyph sits off center in its padded advance, so the chevron is drawn in the rect + const ImVec2 c((ImGui::GetItemRectMin().x + ImGui::GetItemRectMax().x) * 0.5f, + (ImGui::GetItemRectMin().y + ImGui::GetItemRectMax().y) * 0.5f); + const float h = std::round(ImGui::GetFontSize() * 0.25f); + const float dx = left ? h * 0.5f : -h * 0.5f; + ImDrawList *painter = ImGui::GetWindowDrawList(); + painter->PathLineTo(ImVec2(c.x + dx, c.y - h)); + painter->PathLineTo(ImVec2(c.x - dx, c.y)); + painter->PathLineTo(ImVec2(c.x + dx, c.y + h)); + painter->PathStroke(ImGui::GetColorU32(ImGuiCol_Text), ImDrawFlags_None, 1.5f); + ImGui::EndDisabled(); + } + ImGui::PopItemFlag(); + ImGui::SetCursorScreenPos(backup_pos); +} + +struct ScrollableTabBar { ImGuiTabBar *tab_bar; bool overflowing; }; +std::vector scrollable_tab_bars; +} // namespace + +bool beginScrollableTabBar(const char *str_id, ImGuiTabBarFlags flags) { + // the buttons take their room from the bar when the tabs overflowed last frame + ImGuiWindow *window = ImGui::GetCurrentWindow(); + ImGuiTabBar *prev_tab_bar = ImGui::TabBarFindByID(window->GetID(str_id)); + const bool overflowing = prev_tab_bar && prev_tab_bar->WidthAllTabsIdeal > prev_tab_bar->BarRect.GetWidth() + 1.0f; + const float backup_work_max_x = window->WorkRect.Max.x; + if (overflowing) window->WorkRect.Max.x -= scrollButtonsWidth(); + const bool open = ImGui::BeginTabBar(str_id, flags | ImGuiTabBarFlags_FittingPolicyScroll | ImGuiTabBarFlags_NoTabListScrollingButtons); + window->WorkRect.Max.x = backup_work_max_x; + if (open) scrollable_tab_bars.push_back({ImGui::GetCurrentTabBar(), overflowing}); + return open; +} + +void endScrollableTabBar() { + ImGui::EndTabBar(); + const ScrollableTabBar bar = scrollable_tab_bars.back(); + scrollable_tab_bars.pop_back(); + if (!bar.overflowing) return; + drawScrollButtons(bar.tab_bar); + + // the wheel scrolls the tabs while the pointer is over them: a two finger swipe on a touchpad, or a + // mouse wheel like a window that only scrolls sideways. Owning the wheel keeps the window behind still + ImGuiTabBar *tab_bar = bar.tab_bar; + if (ImGui::IsWindowHovered() && ImGui::IsMouseHoveringRect(tab_bar->BarRect.Min, tab_bar->BarRect.Max)) { + ImGui::SetKeyOwner(ImGuiKey_MouseWheelX, tab_bar->ID); + ImGui::SetKeyOwner(ImGuiKey_MouseWheelY, tab_bar->ID); + const ImGuiIO &io = ImGui::GetIO(); + const float wheel = io.MouseWheelH + io.MouseWheel; + if (wheel != 0.0f) { + const float max_scroll = std::max(0.0f, tab_bar->WidthAllTabs - tab_bar->BarRect.GetWidth()); + const float step = std::floor(ImGui::GetFontSize() * 2.0f); + tab_bar->ScrollingTarget = std::clamp(tab_bar->ScrollingTarget - wheel * step, 0.0f, max_scroll); + tab_bar->ScrollingAnim = tab_bar->ScrollingTarget; + } + } +} + diff --git a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.h b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.h new file mode 100644 index 0000000000..6c119a7d7c --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.h @@ -0,0 +1,8 @@ +#pragma once + +#include "imgui.h" + +// a tab bar that scrolls with a pair of chevron buttons at its right end when the tabs overflow, in place of +// imgui's small arrows. Use like BeginTabBar/EndTabBar, the fitting policy is always scroll +bool beginScrollableTabBar(const char *str_id, ImGuiTabBarFlags flags = 0); +void endScrollableTabBar(); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc new file mode 100644 index 0000000000..af926b5bb0 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -0,0 +1,975 @@ +#include "tools/cabana/ui/widgets/signalview.h" + +#include +#include +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" +#include "tools/cabana/ui/icons.h" + +namespace { +constexpr float INDENTATION = 20.0f; +constexpr float H_MARGIN = 3.0f; +constexpr float V_MARGIN = 2.0f; +// signal rows are taller than a frame so the sparklines have room to read +constexpr float SIGNAL_ROW_EXTRA = 5.0f; // the tool button in the row makes it 27 px tall at the 16 px font +constexpr float SIGNAL_ROW_SCALE = 1.25f; +constexpr float FILTER_WIDTH = 160.0f; +constexpr float SPARKLINE_SLIDER_WIDTH = 120.0f; +constexpr float COLLAPSE_ICON_SIZE = 12.0f; +// WARNING: increasing the maximum range can result in severe performance degradation. +// 30s is a reasonable value at present. +constexpr int SPARKLINE_RANGE_MAX = 30; +constexpr float LABEL_FONT = 12.0f; // Inter needs 12 px for 8 px tall digits +constexpr float MINMAX_FONT = 10.0f; +constexpr int COLOR_LABEL_WIDTH = 18; + +std::string signalTypeToString(cabana::Signal::Type type) { + if (type == cabana::Signal::Type::Multiplexor) return "Multiplexor Signal"; + else if (type == cabana::Signal::Type::Multiplexed) return "Multiplexed Signal"; + else return "Normal Signal"; +} + +std::string multiplexIndicator(const cabana::Signal *sig) { + return sig->type == cabana::Signal::Type::Multiplexor ? std::string(" M ") : " m" + std::to_string(sig->multiplex_value) + " "; +} + +std::string nameText(const SignalModel::Item *item) { + return item->type == SignalModel::Item::Sig ? item->sig->name : item->title; +} + +float rowHeight() { + return ImGui::GetFrameHeight(); +} + +// only the top level signal rows are taller; the expanded sub-rows keep the default row height +float signalRowHeight() { + return std::floor((ImGui::GetFrameHeight() + SIGNAL_ROW_EXTRA) * SIGNAL_ROW_SCALE); +} + +// column 0 has a double validator. Returns true when the cell was clicked (row selection); the two cells +// of a row share the row's id scope, so each editor needs its own column id +bool valueDescriptionEditor(int column, std::string *text) { + ImGui::PushID(column); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + validatedInput("##edit", text, column == 0 ? doubleValidator : nullptr); + ImGui::PopStyleVar(); + const bool clicked = ImGui::IsItemActivated() || ImGui::IsItemClicked(); + ImGui::PopID(); + return clicked; +} + +} // namespace + +SignalModel::SignalModel() : root_(new Item) { + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(dbc()->msgUpdated.connect([this](MessageId id) { handleMsgChanged(id); })); + connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { handleMsgChanged(id); })); + connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { handleSignalRemoved(sig); })); +} + +void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) { + Item *parent_item = new Item{.type = Item::Sig, .parent = root_item, .sig = sig, .title = sig->name}; + root_item->children.insert(root_item->children.begin() + pos, parent_item); + std::string 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{.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; + } + } +} + +void SignalModel::setMessage(const MessageId &id) { + msg_id_ = id; + filter_str_ = ""; + refresh(); +} + +void SignalModel::setFilter(const std::string &txt) { + filter_str_ = txt; + refresh(); +} + +void SignalModel::refresh() { + root_.reset(new SignalModel::Item); + if (auto msg = dbc()->msg(msg_id_)) { + for (auto s : msg->getSignals()) { + if (filter_str_.empty() || utils::containsCI(s->name, filter_str_)) { + insertItem(root_.get(), root_->children.size(), s); + } + } + } + modelReset(); + rowsChanged(); +} + +bool SignalModel::isEnabled(const Item *item) { + return !(item->type == Item::MultiplexValue && item->sig->type != cabana::Signal::Type::Multiplexed); +} + +bool SignalModel::isCheckable(const Item *item) { + return item->type == Item::Endian || item->type == Item::Signed; +} + +bool SignalModel::isEditable(const Item *item) { + return item->children.empty() && !isCheckable(item); +} + +int SignalModel::signalRow(const cabana::Signal *sig) const { + for (int i = 0; i < root_->children.size(); ++i) { + if (root_->children[i]->sig == sig) return i; + } + return -1; +} + +std::string SignalModel::valueText(const Item *item) const { + switch (item->type) { + case Item::Sig: return item->sig_val; + case Item::Name: return item->sig->name; + case Item::Size: return std::to_string(item->sig->size); + case Item::Node: return item->sig->receiver_name; + case Item::SignalType: return signalTypeToString(item->sig->type); + case Item::MultiplexValue: return std::to_string(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::Desc: { + std::string val_desc; + for (auto &[val, desc] : item->sig->val_desc) { + if (!val_desc.empty()) val_desc += " "; + val_desc += utils::toString(val) + " \"" + desc + "\""; + } + return val_desc; + } + default: return {}; + } +} + +bool SignalModel::setData(Item *item, const ItemValue &value) { + cabana::Signal s = *item->sig; + switch (item->type) { + case Item::Name: s.name = value.toString(); break; + case Item::Size: s.size = value.toInt(); break; + case Item::Node: s.receiver_name = utils::trimmed(value.toString()); 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::Min: s.min = value.toDouble(); break; + case Item::Max: s.max = value.toDouble(); break; + case Item::Desc: s.val_desc = value.toValueDescription(); break; + default: return false; + } + return saveSignal(item->sig, s); +} + +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) { + std::string text = "There is already a signal with the same name '" + s.name + "'"; + MessageBox::warning("Failed to save signal", text); + return false; + } + + if (s.is_little_endian != origin_s->is_little_endian) { + s.start_bit = flipBitPos(s.start_bit); + } + UndoStack::instance()->push(new EditSignalCommand(msg_id_, origin_s, s)); + return true; +} + +void SignalModel::handleMsgChanged(MessageId id) { + if (id.address == msg_id_.address) { + refresh(); + } +} + +void SignalModel::handleSignalAdded(MessageId id, const cabana::Signal *sig) { + if (id == msg_id_) { + if (filter_str_.empty()) { + int i = dbc()->msg(msg_id_)->indexOf(sig); + insertItem(root_.get(), i, sig); + rowsChanged(); + } else if (utils::containsCI(sig->name, filter_str_)) { + refresh(); + } + } +} + +void SignalModel::handleSignalUpdated(const cabana::Signal *sig) { + if (int row = signalRow(sig); row != -1) { + if (filter_str_.empty()) { + // move row when the order changes. + int to = dbc()->msg(msg_id_)->indexOf(sig); + if (to != row) { + auto item = root_->children[row]; + root_->children.erase(root_->children.begin() + row); + root_->children.insert(root_->children.begin() + to, item); + } + } + } +} + +void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { + if (int row = signalRow(sig); row != -1) { + delete root_->children[row]; + root_->children.erase(root_->children.begin() + row); + rowsChanged(); + } +} + +float SignalView::textWidth(const std::string &text, float font_size) { + ImFont *font = ImGui::GetFont(); + if (!font || ImGui::GetFontSize() <= 0) return 0; // no frame rendered yet + return font->CalcTextSizeA(font_size > 0 ? font_size : ImGui::GetFontSize(), FLT_MAX, 0.0f, text.c_str()).x; +} + +float SignalView::nameColumnWidth(const SignalModel::Item *item, float widget_width, const std::string &text) const { + float spacing = INDENTATION + COLOR_LABEL_WIDTH + 8; + std::string txt = text; + if (item->type == SignalModel::Item::Sig && item->sig->type != cabana::Signal::Type::Normal) { + txt += multiplexIndicator(item->sig); + spacing += H_MARGIN * 2; + } + return std::min(widget_width / 3.0, textWidth(txt) + spacing); +} + +void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const SignalModel::Item *item, int column, + bool selected, const std::string &text, float viewport_x) const { + const float h_margin = H_MARGIN; + const float v_margin = V_MARGIN; + + ImRect rect(option_rect.Min.x + h_margin, option_rect.Min.y + v_margin, option_rect.Max.x - h_margin, option_rect.Max.y - v_margin); + // selection background is painted by the row's Selectable + const ImU32 text_color = selected ? highlightedTextColor() : ImGui::GetColorU32(ImGuiCol_Text); + + if (column == 0) { + if (item->type == SignalModel::Item::Sig) { + // color label + ImRect icon_rect(rect.Min.x, rect.Min.y, rect.Min.x + COLOR_LABEL_WIDTH, rect.Max.y); + painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(item->sig->color.darker(item->highlight ? 125 : 0)), 3.0f); + drawText(painter, icon_rect, std::to_string(item->row() + 1).c_str(), item->highlight ? IM_COL32_WHITE : IM_COL32_BLACK, + nullptr, LABEL_FONT); + + rect.Min.x = icon_rect.Max.x + h_margin * 2; + // multiplexer indicator + if (item->sig->type != cabana::Signal::Type::Normal) { + const std::string indicator = multiplexIndicator(item->sig); + ImRect indicator_rect(rect.Min.x, rect.Min.y, rect.Min.x + ImGui::CalcTextSize(indicator.c_str()).x, rect.Max.y); + painter->AddRectFilled(indicator_rect.Min, indicator_rect.Max, IM_COL32(160, 160, 164, 255), 3.0f); + drawElidedText(painter, indicator_rect, indicator, IM_COL32_WHITE, false); + rect.Min.x = indicator_rect.Max.x + h_margin * 2; + } + } else { + rect.Min.x = viewport_x + INDENTATION + COLOR_LABEL_WIDTH + h_margin * 3; + } + + // name + if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, false); + } else if (column == 1) { + if (!item->sparkline.isEmpty()) { + const ImVec2 sparkline_size = item->sparkline.size; + item->sparkline.draw(painter, rect.Min); + // min-max value + rect.Min.x += sparkline_size.x + 1; + float value_adjust = 10; + if (item->highlight || selected) { + painter->AddLine(rect.Min, ImVec2(rect.Min.x, rect.Max.y), text_color); + rect.Min.x += 5; + rect.Min.y -= v_margin; + rect.Max.y += v_margin; + std::string min = utils::toString(item->sparkline.min_val); + std::string max = utils::toString(item->sparkline.max_val); + drawText(painter, rect, max.c_str(), text_color, nullptr, MINMAX_FONT, ImVec2(0.0f, 0.0f)); + drawText(painter, rect, min.c_str(), text_color, nullptr, MINMAX_FONT, ImVec2(0.0f, 1.0f)); + value_adjust = std::max(textWidth(min, MINMAX_FONT), textWidth(max, MINMAX_FONT)) + 5; + } else if (item->sig->type == cabana::Signal::Type::Multiplexed) { + // display freq of multiplexed signal + char freq[64]; + snprintf(freq, sizeof(freq), "%.2g hz", item->sparkline.freq()); + ImRect freq_rect(rect.Min.x + 5, rect.Min.y, rect.Max.x, rect.Max.y); + drawText(painter, freq_rect, freq, text_color, nullptr, LABEL_FONT, ImVec2(0.0f, 0.5f)); + value_adjust = textWidth(freq, LABEL_FONT) + 10; + } + // signal value + rect.Min.x += value_adjust; + rect.Max.x -= button_size_.x; + if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, true); + } else { + // no sparkline yet: the value still belongs against the buttons, where it sits once there is one + rect.Max.x -= button_size_.x; + if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, true); + } + } +} + +void SignalView::drawEditor(SignalModel::Item *item) { + const bool take_focus = focus_item_ == item; + if (take_focus) focus_item_ = nullptr; + if (item->type == SignalModel::Item::Name || item->type == SignalModel::Item::Node || item->type == SignalModel::Item::Offset || + item->type == SignalModel::Item::Factor || item->type == SignalModel::Item::MultiplexValue || + item->type == SignalModel::Item::Min || item->type == SignalModel::Item::Max) { + ImGuiInputTextCallback validator = nullptr; + if (item->type == SignalModel::Item::Name) validator = nameValidator; + else if (item->type == SignalModel::Item::Node) validator = nodeValidator; + else validator = doubleValidator; + + drawLineEditor(item, validator, take_focus); + } else if (item->type == SignalModel::Item::Size) { + int v = item->sig->size; + if (take_focus) ImGui::SetKeyboardFocusHere(); + bool changed = ImGui::InputInt("##editor", &v, 1, 100, ImGuiInputTextFlags_AutoSelectAll); + if (ImGui::IsItemDeactivated() && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + open_item_ = nullptr; // InputInt already reverted the value; only the commit has to be skipped + return; + } + if (ImGui::IsItemDeactivatedAfterEdit() || (changed && !ImGui::IsItemActive())) { + queueCommit(item, std::clamp(v, 1, CAN_MAX_DATA_BYTES)); + } + // Enter, Escape and a click outside close the editor; the step buttons keep it open + if (ImGui::IsItemDeactivated() && (!ImGui::IsItemHovered() || ImGui::IsKeyPressed(ImGuiKey_Enter, false) || + ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false))) { + open_item_ = nullptr; + } + } else if (item->type == SignalModel::Item::SignalType) { + // the combo editor is closed by Enter and Escape; the cell is painted as text again next frame + if (combo_focused_ && (ImGui::IsKeyPressed(ImGuiKey_Escape, false) || ImGui::IsKeyPressed(ImGuiKey_Enter, false) || + ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false))) { + open_item_ = nullptr; + combo_focused_ = false; + return; + } + std::vector> items; + items.emplace_back(signalTypeToString(cabana::Signal::Type::Normal), (int)cabana::Signal::Type::Normal); + if (!dbc()->msg(model_.msgId())->multiplexor) { + items.emplace_back(signalTypeToString(cabana::Signal::Type::Multiplexor), (int)cabana::Signal::Type::Multiplexor); + } else if (item->sig->type != cabana::Signal::Type::Multiplexor) { + items.emplace_back(signalTypeToString(cabana::Signal::Type::Multiplexed), (int)cabana::Signal::Type::Multiplexed); + } + std::vector names; + int current = -1; // -1 when the current type is not an item (Multiplexor) + for (int i = 0; i < items.size(); ++i) { + names.push_back(items[i].first.c_str()); + if (items[i].second == (int)item->sig->type) current = i; + } + const ImGuiID popup_id = ImHashStr("##ComboPopup", 0, ImGui::GetID("##editor")); + if (take_focus) ImGui::SetKeyboardFocusHere(); + if (ImGui::Combo("##editor", ¤t, names.data(), names.size())) { + queueCommit(item, items[current].second); + open_item_ = nullptr; // commit and close the editor + } + combo_focused_ = ImGui::IsItemFocused() || ImGui::IsPopupOpen(popup_id, ImGuiPopupFlags_None); + if (!take_focus && !combo_focused_) open_item_ = nullptr; // the editor is closed when it loses the focus + } else if (item->type == SignalModel::Item::Desc) { + ImGui::PushStyleColor(ImGuiCol_Header, (ImU32)0); + const bool clicked = ImGui::Selectable("##editor", false, 0, ImVec2(0, rowHeight())); + ImGui::PopStyleColor(); + drawElidedText(ImGui::GetWindowDrawList(), ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()), model_.valueText(item), + highlightedTextColor(), false); + if (clicked || take_focus) { + desc_dlg_ = std::make_unique(item->sig->val_desc); + desc_dlg_->title = item->sig->name; + desc_sig_ = item->sig; + } + } else { + // plain text input, no validator + drawLineEditor(item, nullptr, take_focus); + } +} + +void SignalView::commitEditor() { + SignalModel::Item *item = editing_item_; + std::string text = edit_text_; + closeEditor(); + if (item && validateEditor(item, text) == ValidState::Acceptable) { + queueCommit(item, text); + } +} + +void SignalView::closeEditor() { + editing_item_ = open_item_ = focus_item_ = nullptr; + editor_active_ = refocus_editor_ = enter_pressed_ = combo_focused_ = false; + pending_commit_ = nullptr; // the items it captured are deleted by the caller +} + +// validate the editor of `item`; mutates `text` like the name validator does (spaces -> '_') +ValidState SignalView::validateEditor(const SignalModel::Item *item, std::string &text) { + if (item->type == SignalModel::Item::Name) return validateName(text); + if (item->type == SignalModel::Item::Node) return validateNodes(text); + if (item->type == SignalModel::Item::Offset || item->type == SignalModel::Item::Factor || + item->type == SignalModel::Item::MultiplexValue || item->type == SignalModel::Item::Min || + item->type == SignalModel::Item::Max) { + return validateDouble(text); + } + return ValidState::Acceptable; // no validator +} + +// Enter and focus out only commit when the validator reports Acceptable (an Intermediate or Invalid value +// keeps the editor open with the typed text and commits nothing), Escape reverts to the value the editor +// was opened with. +void SignalView::drawLineEditor(SignalModel::Item *item, ImGuiInputTextCallback validator, bool take_focus) { + const bool editing = editing_item_ == item; + const bool was_active = editing && editor_active_; // the editor had the focus at the end of the last frame + + std::string text = editing ? edit_text_ : model_.valueText(item); + if (take_focus) ImGui::SetKeyboardFocusHere(); + if (editing && refocus_editor_) { + ImGui::SetKeyboardFocusHere(); // keep the focus when the input is not acceptable + refocus_editor_ = false; + } + validatedInput("##editor", &text, validator, "", ImGuiInputTextFlags_AutoSelectAll); + if (ImGui::IsItemActivated()) editing_item_ = item; + if (editing_item_ != item) return; + + edit_text_ = text; + editor_active_ = ImGui::IsItemActive(); + if (was_active) { + if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + // InputText already reverted the text; only the commit has to be skipped + editing_item_ = open_item_ = nullptr; + enter_pressed_ = false; + return; + } + if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) { + enter_pressed_ = true; + } + } + if (ImGui::IsItemDeactivated()) { + const bool by_enter = std::exchange(enter_pressed_, false); + if (!ImGui::IsItemDeactivatedAfterEdit()) { + editing_item_ = open_item_ = nullptr; // nothing was typed, nothing to commit + } else if (validateEditor(item, edit_text_) == ValidState::Acceptable) { + queueCommit(item, edit_text_); + editing_item_ = open_item_ = nullptr; + } else if (by_enter) { + refocus_editor_ = true; // the editor stays open with the text the user typed + } else { + editing_item_ = open_item_ = nullptr; + } + } +} + +void SignalView::queueCommit(SignalModel::Item *item, const ItemValue &value) { + pending_commit_ = [this, item, value]() { model_.setData(item, value); }; +} + +void SignalView::drawValueDescriptionDlg() { + if (!desc_dlg_) return; + if (desc_dlg_->draw()) return; + + if (desc_dlg_->accepted) { + // the dialog closed: apply to the Desc item of the edited signal + for (auto sig_item : model_.root()->children) { + if (sig_item->sig != desc_sig_) continue; + for (auto child : sig_item->children) { + if (child->type != SignalModel::Item::ExtraInfo) continue; + for (auto extra : child->children) { + if (extra->type == SignalModel::Item::Desc) queueCommit(extra, desc_dlg_->val_desc); + } + } + } + } + desc_dlg_.reset(); + desc_sig_ = nullptr; +} + +SignalView::SignalView(ChartsWidget *charts) : charts_(charts) { + settings.sparkline_range = std::clamp(settings.sparkline_range, 1, SPARKLINE_RANGE_MAX); + + // seed the size of the [plot][remove] widget (two 22px tool buttons plus the spacing) so the first + // updateState() calls already leave room for the sparklines + button_size_ = ImVec2(22 * 2 + TOOLBAR_ITEM_SPACING, 22); + updateToolBar(); + + connections_.push_back(model_.rowsChanged.connect([this]() { rowsChanged(); })); + // a reset closes the open editors; the items they point at are deleted by refresh() + connections_.push_back(model_.modelReset.connect([this]() { + closeEditor(); + // the visible range is computed while drawing; reset it to the top so the sparklines are ready in the + // frame that paints them + if (first_visible_row_ != -1) { + last_visible_row_ = std::min(model_.rowCount() - 1, last_visible_row_ - first_visible_row_); + first_visible_row_ = 0; + } + })); + connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); + // the sig pointers die with the signal + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { + if (desc_sig_ == sig) desc_sig_ = nullptr; + if ((editing_item_ && editing_item_->sig == sig) || (open_item_ && open_item_->sig == sig) || + (focus_item_ && focus_item_->sig == sig)) closeEditor(); + handleSignalRemoved(sig); + })); + connections_.push_back(dbc()->fileChanged.connect([this]() { + desc_sig_ = nullptr; + closeEditor(); + handleSignalRemoved(nullptr); + })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); +} + +std::string SignalView::whatsThis() const { + return R"( + Signal view
+ )"; +} + +void SignalView::setMessage(const MessageId &id) { + filter_edit_.clear(); + model_.setMessage(id); +} + +void SignalView::rowsChanged() { + updateToolBar(); + updateChartState(); + updateState(); +} + +void SignalView::rowClicked(SignalModel::Item *item) { + if (item->type == SignalModel::Item::Sig || item->type == SignalModel::Item::ExtraInfo) { + item->expanded = !item->expanded; + } +} + +void SignalView::selectSignal(const cabana::Signal *sig, bool expand) { + if (int row = model_.signalRow(sig); row != -1) { + auto item = model_.root()->children[row]; + if (expand) { + item->expanded = !item->expanded; + } + scroll_to_sig_ = sig; // scroll the signal to the top + current_sig_ = sig; + current_type_ = SignalModel::Item::Sig; + } +} + +void SignalView::updateChartState() { + for (auto item : model_.root()->children) { + item->chart_opened = charts_->hasSignal(model_.msgId(), item->sig); + } +} + +void SignalView::signalHovered(const cabana::Signal *sig) { + auto &children = model_.root()->children; + for (int i = 0; i < children.size(); ++i) { + children[i]->highlight = children[i]->sig == sig; + } +} + +void SignalView::updateToolBar() { + signal_count_lb_ = "Signals: " + std::to_string(model_.rowCount()); + sparkline_label_ = utils::formatSeconds(settings.sparkline_range); +} + +void SignalView::setSparklineRange(int value) { + settings.sparkline_range = value; + updateToolBar(); + updateState(); +} + +void SignalView::handleSignalAdded(MessageId id, const cabana::Signal *sig) { + if (id.address == model_.msgId().address) { + selectSignal(sig); + } +} + +void SignalView::handleSignalUpdated(const cabana::Signal *sig) { + if (int row = model_.signalRow(sig); row != -1) + updateState(); +} + +void SignalView::handleSignalRemoved(const cabana::Signal *sig) { + if (!sig || current_sig_ == sig) { + // the current index moves to the row that took the removed one, or to the last row + current_sig_ = nullptr; + auto &children = model_.root()->children; + if (sig && !children.empty() && current_row_ >= 0) { + current_sig_ = children[std::min(current_row_, children.size() - 1)]->sig; + current_type_ = SignalModel::Item::Sig; + } + } + if (!sig || scroll_to_sig_ == sig) scroll_to_sig_ = nullptr; + if (!sig || hovered_sig_ == sig) hovered_sig_ = nullptr; +} + +float SignalView::widestValueWidth(const cabana::Signal *sig) { + const double raw_max = sig->is_signed ? std::ldexp(1.0, sig->size - 1) - 1 : std::ldexp(1.0, sig->size) - 1; + const double raw_min = sig->is_signed ? -std::ldexp(1.0, sig->size - 1) : 0.0; + float width = 0; + for (double raw : {raw_min, raw_max}) { + width = std::max(width, textWidth(sig->formatValue(raw * sig->factor + sig->offset))); + } + for (const auto &[_, desc] : sig->val_desc) { + width = std::max(width, textWidth(desc)); + } + return width; +} + +void SignalView::updateState(const std::set *msgs) { + const auto &last_msg = can->lastMessage(model_.msgId()); + if (model_.rowCount() == 0 || (msgs && !msgs->count(model_.msgId())) || last_msg.dat.size() == 0) return; + + // sized for the widest value the signals can produce, not the widest one in the last message: sizing + // to the current values moved the sparklines every time a value changed length + float max_value_width = 0; + 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); + } + max_value_width = std::max(max_value_width, widestValueWidth(item->sig)); + } + + if (first_visible_row_ != -1 && last_visible_row_ != -1 && last_visible_row_ < model_.rowCount()) { + const float min_max_width = textWidth("-000.00", MINMAX_FONT) + 5; + float available_width = value_column_width_ - button_size_.x; + float value_width = std::min(max_value_width + min_max_width, available_width / 2); + ImVec2 size(std::floor(available_width - value_width), + std::floor(signalRowHeight() - V_MARGIN * 2)); + + // the window ends at the playback clock, not at the last message: its timestamp only moves when a + // message of this id arrives, so a slow message held the sparkline still for several updates and + // then jumped it, which reads as a hitch at the message rate + const double window_end = can->currentSec(); + // a little data from before the window: the sparkline clips it, so the oldest samples and their + // points slide off the left edge instead of disappearing the moment they age out + const double lead_in = settings.sparkline_range * 0.05; + // plain locals: capturing structured bindings in a lambda is C++20 + const auto range = can->eventsInRange(model_.msgId(), std::make_pair(window_end - settings.sparkline_range - lead_in, window_end)); + const CanEventIter first = range.first, last = range.second; + std::vector> futures; + for (int i = first_visible_row_; i <= last_visible_row_; ++i) { + auto item = model_.root()->children[i]; + futures.push_back(ThreadPool::instance().run([item, first, last, size, window_end]() { + item->sparkline.update(item->sig, first, last, settings.sparkline_range, size, window_end); + })); + } + for (auto &f : futures) f.get(); + } +} + +// the sparkline label, the range slider and the collapse button +float SignalView::toolBarRightWidth(const std::string &range_label) { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::CalcTextSize(range_label.c_str()).x + style.ItemSpacing.x + SPARKLINE_SLIDER_WIDTH + style.ItemSpacing.x + + ImGui::GetFont()->CalcTextSizeA(COLLAPSE_ICON_SIZE, FLT_MAX, 0.0f, icon::DASH_SQUARE).x + style.FramePadding.x * 2; +} + +// the width at which the tool bar stops squishing: the signal count and the filter box on the left, the +// sparkline controls on the right, plus the borders and padding of the view's own child window +float SignalView::minimumWidth() { + const ImGuiStyle &style = ImGui::GetStyle(); + const float left_width = ImGui::CalcTextSize("Signals: 000").x + style.ItemSpacing.x + FILTER_WIDTH; + // formatSeconds is mm:ss for every value the range slider allows + return left_width + style.ItemSpacing.x + toolBarRightWidth("00:00") + (style.WindowPadding.x + style.ChildBorderSize) * 2; +} + +void SignalView::draw() { + if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_Borders)) { + ImGui::EndChild(); + return; + } + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(signal_count_lb_.c_str()); + ImGui::SameLine(); + ImGui::SetNextItemWidth(FILTER_WIDTH); + if (clearableInput("##filter_edit", &filter_edit_, "Filter Signal", nonWhitespaceValidator)) { + model_.setFilter(filter_edit_); + } + + // stretch: the sparkline controls sit at the right edge + alignRight(toolBarRightWidth(sparkline_label_)); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(sparkline_label_.c_str()); + ImGui::SameLine(); + int range = settings.sparkline_range; + if (fusionSliderInt("##sparkline_range_slider", &range, 1, SPARKLINE_RANGE_MAX, SPARKLINE_SLIDER_WIDTH)) { + setSparklineRange(range); + } + ImGui::SetItemTooltip("Sparkline time range"); + ImGui::SameLine(); + // auto-raise tool button with a 12x12 icon + ImGui::PushFont(ImGui::GetFont(), COLLAPSE_ICON_SIZE); + const bool collapse = toolButton("collapse_all", icon::DASH_SQUARE, "Collapse All"); + ImGui::PopFont(); + if (collapse) collapseAll(); + + drawTree(); + drawValueDescriptionDlg(); + // model changes run after the tree is drawn: dbc()->signalUpdated/signalRemoved reorder or delete the rows + if (pending_commit_) std::exchange(pending_commit_, nullptr)(); + if (pending_action_) std::exchange(pending_action_, nullptr)(); + current_row_ = model_.signalRow(current_sig_); // used when the row is removed + + ImGui::EndChild(); +} + +void SignalView::collapseAll() { + commitEditor(); // the editor loses the focus, which commits it + for (auto item : model_.root()->children) { + item->expanded = false; + for (auto child : item->children) child->expanded = false; + } +} + +void SignalView::drawTree() { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + const float min_height = std::max(ImGui::GetContentRegionAvail().y, 300.0f); + const bool visible = ImGui::BeginChild("tree", ImVec2(0, min_height), ImGuiChildFlags_None); + ImGui::PopStyleVar(); + if (visible) { + DrawContext ctx{ImGui::GetWindowDrawList(), ImGui::GetCursorScreenPos().x, ImGui::GetContentRegionAvail().x, rowHeight()}; + // the press that closes an open editor is consumed by the focus change, the index widgets never see it + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) editor_open_on_press_ = open_item_ != nullptr; + + int first_visible = -1, last_visible = -1; + auto &children = model_.root()->children; + for (int i = 0; i < children.size(); ++i) { + ctx.any_visible = false; + const bool header_visible = drawItem(children[i], 0, ctx); + if (header_visible && first_visible == -1) first_visible = i; + if (ctx.any_visible) last_visible = i; + } + if (first_visible == -1 && last_visible != -1) last_visible = -1; + // the rows that just became visible have no sparkline yet + bool changed = first_visible != first_visible_row_ || last_visible != last_visible_row_; + first_visible_row_ = first_visible; + last_visible_row_ = last_visible; + scroll_to_sig_ = nullptr; + + if (ctx.name_width > 0) name_column_width_ = ctx.name_width; + if (ctx.value_column_width > 0 && ctx.value_column_width != value_column_width_) { + value_column_width_ = ctx.value_column_width; + changed = true; + } + if (changed) updateState(); + + // a press on the viewport that hits no row clears the selection and the current index; rowClicked() + // does not run + if (!ctx.mouse_on_row && ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + current_sig_ = nullptr; + current_type_ = SignalModel::Item::Root; + } + + if (ctx.hovered_sig != hovered_sig_) { + hovered_sig_ = ctx.hovered_sig; + highlight(hovered_sig_); + } + } + ImGui::EndChild(); + ImGui::PopStyleVar(); +} + +bool SignalView::drawItem(SignalModel::Item *item, int depth, DrawContext &ctx) { + const bool selected = item->sig == current_sig_ && item->type == current_type_; + const float row_height = item->type == SignalModel::Item::Sig ? signalRowHeight() : ctx.row_height; + const ImVec2 row_min = ImGui::GetCursorScreenPos(); + const ImVec2 row_max(row_min.x + ctx.width, row_min.y + row_height); + const bool row_visible = ImGui::IsRectVisible(row_min, row_max); + ctx.any_visible |= row_visible; + + ImGui::PushID(item); + ImGui::BeginDisabled(!SignalModel::isEnabled(item)); + const bool row_clicked = viewSelectable("##row", selected, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, row_height)); + // a press on the branch indicator only toggles the expansion; the current index does not change and + // rowClicked() does not run + const float branch_x = row_min.x + depth * INDENTATION; + const bool on_branch = !item->children.empty() && ImGui::GetMousePos().x >= branch_x && + ImGui::GetMousePos().x < branch_x + INDENTATION; + if (row_clicked && on_branch) { + item->expanded = !item->expanded; + } else if (row_clicked) { + current_sig_ = item->sig; + current_type_ = item->type; + // the new current item opens its editor. The name column and the non-editable cells (signal rows, + // check boxes) have no editor, so a click there only makes the cell current. + closeEditor(); + if (SignalModel::isEditable(item) && ImGui::GetMousePos().x >= row_min.x + name_column_width_) { + focus_item_ = open_item_ = item; + } + rowClicked(item); + } + if (item->type == SignalModel::Item::Sig && item->sig == scroll_to_sig_) { + ImGui::SetScrollHereY(0.0f); + scroll_to_sig_ = nullptr; + } + if (ImGui::IsMouseHoveringRect(row_min, row_max)) { + ctx.mouse_on_row = true; + if (ImGui::IsWindowHovered()) ctx.hovered_sig = item->sig; + } + + if (!item->children.empty()) { + const float arrow_size = ImGui::GetFontSize() * 0.7f; + ImGui::RenderArrow(ctx.draw_list, ImVec2(row_min.x + depth * INDENTATION + 4.0f, row_min.y + (row_height - arrow_size) * 0.5f), + ImGui::GetColorU32(ImGuiCol_Text), item->expanded ? ImGuiDir_Down : ImGuiDir_Right, 0.7f); + } + + // every row is measured, the header sizes column 0 to the contents of the whole tree + const std::string text0 = nameText(item); + ctx.name_width = std::max(ctx.name_width, nameColumnWidth(item, ctx.width, text0)); + const ImRect rect1(ImVec2(row_min.x + name_column_width_, row_min.y), row_max); + ctx.value_column_width = rect1.GetWidth(); + + // a row outside the viewport is not painted and has no index widget, like a QTreeView row. The row that + // holds the open editor is always submitted, so scrolling it out does not drop the edit. + const bool editor_open = selected && open_item_ == item; + if (row_visible || editor_open) { + const ImRect rect0(ImVec2(row_min.x + (depth + 1) * INDENTATION, row_min.y), ImVec2(row_min.x + name_column_width_, row_max.y)); + paintCell(ctx.draw_list, rect0, item, 0, selected, text0, ctx.viewport_x); + if (item->type == SignalModel::Item::Sig && ImGui::IsMouseHoveringRect(ImVec2(row_min.x, row_min.y), rect0.Max) && + ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip) && ImGui::BeginTooltip()) { + ImGui::TextUnformatted(utils::stripHtml(utils::signalToolTip(item->sig)).c_str()); + ImGui::EndTooltip(); + } + + if (item->type == SignalModel::Item::Sig) { + paintCell(ctx.draw_list, rect1, item, 1, selected, item->sig_val, ctx.viewport_x); + drawIndexWidget(item, rect1); + } else if (SignalModel::isCheckable(item)) { + bool checked = item->type == SignalModel::Item::Endian ? item->sig->is_little_endian : item->sig->is_signed; + ImGui::SetCursorScreenPos(ImVec2(rect1.Min.x + H_MARGIN, rect1.Min.y)); + if (checkBox("##check", &checked)) queueCommit(item, checked); + } else if (SignalModel::isEditable(item) && editor_open) { + // only the current item gets an editor; the others are painted as text + ImGui::SetCursorScreenPos(rect1.Min); + ImGui::SetNextItemWidth(rect1.GetWidth()); + drawEditor(item); + } else { + paintCell(ctx.draw_list, rect1, item, 1, selected, model_.valueText(item), ctx.viewport_x); + } + } + ImGui::EndDisabled(); + ImGui::PopID(); + ImGui::SetCursorScreenPos(ImVec2(row_min.x, row_max.y)); + + if (item->expanded) { + for (auto child : item->children) drawItem(child, depth + 1, ctx); + } + return row_visible; +} + +void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { + // plot_btn + remove_btn, right aligned in the value column + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(3.0f, 2.0f)); + const ImVec2 btn_size(ImGui::CalcTextSize(icon::GRAPH_UP).x + 6.0f, ImGui::GetFrameHeight()); + const ImVec2 size(btn_size.x * 2 + TOOLBAR_ITEM_SPACING, btn_size.y); + ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - size.x, rect.Min.y + (rect.GetHeight() - size.y) * 0.5f)); + + const auto sig = item->sig; + const bool checked = item->chart_opened; + if (checked) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); + if (ImGui::Button((std::string(icon::GRAPH_UP) + "##plot").c_str(), btn_size) && !editor_open_on_press_) { + item->chart_opened = !checked; + showChart(model_.msgId(), sig, item->chart_opened, ImGui::GetIO().KeyShift); + } + if (checked) ImGui::PopStyleColor(); + ImGui::SetItemTooltip("%s", checked ? "Close Plot" : "Show Plot\nSHIFT click to add to previous opened plot"); + ImGui::SameLine(0.0f, TOOLBAR_ITEM_SPACING); + if (ImGui::Button((std::string(icon::X) + "##remove").c_str(), btn_size) && !editor_open_on_press_) { + pending_action_ = [this, sig]() { UndoStack::instance()->push(new RemoveSigCommand(model_.msgId(), sig)); }; + } + ImGui::SetItemTooltip("Remove signal"); + ImGui::PopStyleVar(); + button_size_ = size; +} + +ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions) { + for (auto &[val, desc] : descriptions) { + table_.emplace_back(utils::toString(val), desc); + } +} + +bool ValueDescriptionDlg::draw() { + const std::string popup_id = title + "###ValueDescriptionDlg"; + if (!opened_) { + ImGui::OpenPopup(popup_id.c_str()); + opened_ = true; + } + setNextDialogWindow(ImVec2(500.0f, 0.0f)); + bool open = true; + // not drawn while the dock is collapsed or another modal is on top; only closed once the popup is gone + if (!ImGui::BeginPopupModal(popup_id.c_str(), &open, ImGuiWindowFlags_NoSavedSettings)) return ImGui::IsPopupOpen(popup_id.c_str()); + + bool closing = false; + if (ImGui::Button(icon::PLUS)) { + table_.emplace_back("", ""); + } + ImGui::SameLine(); + ImGui::BeginDisabled(current_row_ == -1); + if (ImGui::Button(icon::DASH) && current_row_ < table_.size()) { + table_.erase(table_.begin() + current_row_); + current_row_ = -1; + } + ImGui::EndDisabled(); + + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY; + if (ImGui::BeginTable("table", 3, flags, ImVec2(0.0f, 300.0f))) { + ImGui::TableSetupScrollFreeze(1, 1); + // vertical header: the 1 based row number + ImGui::TableSetupColumn("##row_number", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoHeaderLabel, + ImGui::CalcTextSize("000").x + ImGui::GetStyle().CellPadding.x * 2); + ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("Description", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + for (int row = 0; row < table_.size(); ++row) { + ImGui::PushID(row); + ImGui::TableNextRow(); + if (row == current_row_) ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::GetColorU32(ImGuiCol_Header)); + ImGui::TableSetColumnIndex(0); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(std::to_string(row + 1).c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::SetNextItemWidth(-FLT_MIN); + if (valueDescriptionEditor(0, &table_[row].first)) current_row_ = row; + ImGui::TableSetColumnIndex(2); + ImGui::SetNextItemWidth(-FLT_MIN); + if (valueDescriptionEditor(1, &table_[row].second)) current_row_ = row; + ImGui::PopID(); + } + ImGui::EndTable(); + } + + bool accept = false, reject = false; + if (dialogButtons("OK", &accept, &reject)) { + if (accept) save(); + closing = true; + } + if (!open) closing = true; + if (closing) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + return !closing; +} + +void ValueDescriptionDlg::save() { + for (int i = 0; i < table_.size(); ++i) { + std::string val = utils::trimmed(table_[i].first); + std::string desc = utils::trimmed(table_[i].second); + if (!val.empty() && !desc.empty()) { + val_desc.push_back({utils::toDouble(val), desc}); + } + } + accepted = true; +} diff --git a/openpilot/tools/cabana/ui/widgets/signalview.h b/openpilot/tools/cabana/ui/widgets/signalview.h new file mode 100644 index 0000000000..eaeb6d2aed --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/signalview.h @@ -0,0 +1,201 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/core/observable.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/chart/sparkline.h" +#include "tools/cabana/utils/strings.h" + +// the value SignalModel::setData takes: text, a number, a check state or a value table +class ItemValue { +public: + ItemValue(const std::string &s) : str_(s) {} + ItemValue(int v) : str_(std::to_string(v)) {} + ItemValue(bool v) : str_(v ? "1" : "0") {} + ItemValue(const ValueDescription &v) : val_desc_(v) {} + ItemValue(const char *) = delete; // a literal would bind to ItemValue(bool) + std::string toString() const { return str_; } + int toInt() const { return utils::toInt(str_); } + bool toBool() const { return str_ == "1"; } + double toDouble() const { return utils::toDouble(str_); } + const ValueDescription &toValueDescription() const { return val_desc_; } + +private: + std::string str_; + ValueDescription val_desc_; +}; + +class SignalModel { +public: + struct Item { + enum Type {Root, Sig, Name, Size, Node, Endian, Signed, Offset, Factor, SignalType, MultiplexValue, ExtraInfo, Unit, Comment, Min, Max, Desc }; + ~Item() { for (auto c : children) delete c; } + inline int row() const { + 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; + std::vector children; + + const cabana::Signal *sig = nullptr; + std::string title; + bool highlight = false; + std::string sig_val = "-"; + Sparkline sparkline; + bool expanded = false; + bool chart_opened = false; // plot_btn checked state + }; + + SignalModel(); + Item *root() const { return root_.get(); } + const MessageId &msgId() const { return msg_id_; } + int rowCount() const { return root_->children.size(); } + static bool isEnabled(const Item *item); + static bool isEditable(const Item *item); // a leaf cell in the value column with an editor + static bool isCheckable(const Item *item); // Endian and Signed + std::string valueText(const Item *item) const; + bool setData(Item *item, const ItemValue &value); + void setMessage(const MessageId &id); + void setFilter(const std::string &txt); + bool saveSignal(const cabana::Signal *origin_s, cabana::Signal &s); + int signalRow(const cabana::Signal *sig) const; + + Observable<> rowsChanged; // rows were added, removed or reset + Observable<> modelReset; // the items are gone: the view closes its editors + +private: + void insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig); + void handleSignalAdded(MessageId id, const cabana::Signal *sig); + void handleSignalUpdated(const cabana::Signal *sig); + void handleSignalRemoved(const cabana::Signal *sig); + void handleMsgChanged(MessageId id); + void refresh(); + + MessageId msg_id_; + std::string filter_str_; + std::unique_ptr root_; + Connections connections_; +}; + +// non-blocking: draw() returns false once closed, `accepted` tells whether Ok was pressed +class ValueDescriptionDlg { +public: + ValueDescriptionDlg(const ValueDescription &descriptions); + bool draw(); + ValueDescription val_desc; + std::string title; + bool accepted = false; + +private: + void save(); + std::vector> table_; // rows of {Value, Description} + int current_row_ = -1; + bool opened_ = false; +}; + +class SignalView { +public: + SignalView(ChartsWidget *charts); + void setMessage(const MessageId &id); + void draw(); + static float minimumWidth(); + void signalHovered(const cabana::Signal *sig); // handler for BinaryView::signalHovered + void updateChartState(); + void selectSignal(const cabana::Signal *sig, bool expand = false); + bool saveSignal(const cabana::Signal *origin, cabana::Signal &s) { return model_.saveSignal(origin, s); } + std::string whatsThis() const; + + Observable highlight; + Observable showChart; + +private: + void rowsChanged(); + void rowClicked(SignalModel::Item *item); + static float toolBarRightWidth(const std::string &range_label); + void updateToolBar(); + void setSparklineRange(int value); + void handleSignalAdded(MessageId id, const cabana::Signal *sig); + void handleSignalUpdated(const cabana::Signal *sig); + void handleSignalRemoved(const cabana::Signal *sig); // drops the row pointers to a removed signal (nullptr: all) + void updateState(const std::set *msgs = nullptr); + + struct DrawContext { + ImDrawList *draw_list; + float viewport_x; + float width; + float row_height; + float name_width = 0; + float value_column_width = 0; + bool any_visible = false; + bool mouse_on_row = false; + const cabana::Signal *hovered_sig = nullptr; + }; + void drawTree(); + bool drawItem(SignalModel::Item *item, int depth, DrawContext &ctx); // returns whether the row is visible + void drawIndexWidget(SignalModel::Item *item, const ImRect &rect); // the [plot][remove] widget + void collapseAll(); + static float widestValueWidth(const cabana::Signal *sig); + + // viewport_x: left edge of the tree viewport + void paintCell(ImDrawList *painter, const ImRect &rect, const SignalModel::Item *item, int column, bool selected, + const std::string &text, float viewport_x) const; + float nameColumnWidth(const SignalModel::Item *item, float widget_width, const std::string &text) const; + // draws the editor for `item` at the cursor; commits through queueCommit on focus out + void drawEditor(SignalModel::Item *item); + // queues the commit in pending_commit_: EditSignalCommand fires dbc()->signalUpdated synchronously, which reorders + // the rows, so the model is only changed after the tree is drawn (see draw) + void queueCommit(SignalModel::Item *item, const ItemValue &value); + void drawValueDescriptionDlg(); // continuation of the ValueDescriptionDlg opened in drawEditor + static float textWidth(const std::string &text, float font_size = 0); + void closeEditor(); + void commitEditor(); // Qt commits an open editor on focus out + // only an Acceptable value is committed + void drawLineEditor(SignalModel::Item *item, ImGuiInputTextCallback validator, bool take_focus); + static ValidState validateEditor(const SignalModel::Item *item, std::string &text); + + float value_column_width_ = 0; + float name_column_width_ = 150; + bool editor_open_on_press_ = false; + // computed while drawing the tree: the first top-level row whose own row is visible (a signal whose header + // is scrolled out but whose children are visible is skipped), and the last top-level row with any visible row + int first_visible_row_ = -1; + int last_visible_row_ = -1; + const cabana::Signal *current_sig_ = nullptr; + int current_row_ = -1; // row of current_sig_ at the end of the last draw() + SignalModel::Item::Type current_type_ = SignalModel::Item::Root; + const cabana::Signal *scroll_to_sig_ = nullptr; + const cabana::Signal *hovered_sig_ = nullptr; + std::function pending_action_; // button clicks that destroy rows run after the tree is drawn + std::string sparkline_label_; + std::string filter_edit_; + ChartsWidget *charts_; + std::string signal_count_lb_; + + ImVec2 button_size_ = {}; + // the editor is created for the item that just became current and takes the focus + SignalModel::Item *focus_item_ = nullptr; + // the item whose editor is open; closeEditor() returns the cell to the painted text while the row stays current + SignalModel::Item *open_item_ = nullptr; + std::function pending_commit_; + SignalModel::Item *editing_item_ = nullptr; // the open text editor + std::string edit_text_; + bool editor_active_ = false; // editor had the keyboard focus last frame + bool refocus_editor_ = false; // reopen the editor rejected by the validator + bool enter_pressed_ = false; + bool combo_focused_ = false; // the SignalType combo had the focus last frame + std::unique_ptr desc_dlg_; + const cabana::Signal *desc_sig_ = nullptr; + SignalModel model_; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/tabbar.cc b/openpilot/tools/cabana/ui/widgets/tabbar.cc new file mode 100644 index 0000000000..5e194b965e --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/tabbar.cc @@ -0,0 +1,92 @@ +#include "tools/cabana/ui/widgets/tabbar.h" + +#include +#include + +#include "tools/cabana/ui/widgets/scrollabletabbar.h" + +int TabBar::addTab(const std::string &text) { + tabs_.push_back({text, 0, next_id_++}); + int index = count() - 1; + if (current_index_ == -1) { // the first tab is current + current_index_ = index; + select_current_ = true; + currentChanged(index); + } + return index; +} + +void TabBar::setCurrentIndex(int index) { + if (index == current_index_ || index < -1 || index >= count()) return; + current_index_ = index; + select_current_ = true; + currentChanged(index); +} + +int TabBar::tabAt(const ImVec2 &pos) const { + for (int i = 0; i < count(); ++i) { + if (tabs_[i].rect.Contains(pos)) return i; + } + return -1; +} + +void TabBar::removeTab(int index) { + tabs_.erase(tabs_.begin() + index); + if (index == current_index_) { + // select the tab that moved into this index, else the one to the left + current_index_ = count() ? std::min(index, count() - 1) : -1; + select_current_ = true; + currentChanged(current_index_); + } else if (index < current_index_) { + --current_index_; + } +} + +void TabBar::moveTab(int from, int to) { + if (from == to || from < 0 || from >= count() || to < 0 || to >= count()) return; + const int current_id = current_index_ >= 0 ? tabs_[current_index_].id : -1; + Tab tab = std::move(tabs_[from]); + tabs_.erase(tabs_.begin() + from); + tabs_.insert(tabs_.begin() + to, std::move(tab)); + for (int i = 0; i < count(); ++i) { + if (tabs_[i].id == current_id) current_index_ = i; + } + select_current_ = true; // imgui orders the tabs as submitted only when a tab is (re)selected +} + +void TabBar::draw() { + if (auto_hide_ && count() < 2) return; // auto hidden with fewer than two tabs + ImGui::PushID(this); + // no default tooltip, the tabs carry their own + if (!(scroll_buttons_ ? beginScrollableTabBar("##tabbar", ImGuiTabBarFlags_NoTooltip) : ImGui::BeginTabBar("##tabbar", ImGuiTabBarFlags_NoTooltip))) { + ImGui::PopID(); + return; + } + // every tab gets a close button, not only the hovered/selected one + ImGuiStyle &style = ImGui::GetStyle(); + const float close_button_min_width = tabs_closable_ ? std::exchange(style.TabCloseButtonMinWidthUnselected, -1.0f) : 0.0f; + // setCurrentIndex requests are applied on the next frame + const bool select_current = std::exchange(select_current_, false); + int close_index = -1; + for (int i = 0; i < count(); ++i) { + bool open = true; + const std::string label = tabs_[i].text + "###tab" + std::to_string(tabs_[i].id); + const ImGuiTabItemFlags flags = (select_current && i == current_index_) ? ImGuiTabItemFlags_SetSelected : 0; + if (ImGui::BeginTabItem(label.c_str(), tabs_closable_ ? &open : nullptr, flags)) { + // a programmatic selection takes effect on the next frame, ignore the old tab until then + if (!select_current && i != current_index_) { + current_index_ = i; + currentChanged(i); + } + ImGui::EndTabItem(); + } + tabs_[i].rect = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + if (!tabs_[i].tooltip.empty()) ImGui::SetItemTooltip("%s", tabs_[i].tooltip.c_str()); + tabContextMenu(i); + if (!open) close_index = i; + } + if (tabs_closable_) style.TabCloseButtonMinWidthUnselected = close_button_min_width; + scroll_buttons_ ? endScrollableTabBar() : ImGui::EndTabBar(); + ImGui::PopID(); + if (close_index >= 0) tabCloseRequested(close_index); +} diff --git a/openpilot/tools/cabana/ui/widgets/tabbar.h b/openpilot/tools/cabana/ui/widgets/tabbar.h new file mode 100644 index 0000000000..9a8b7c7835 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/tabbar.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/core/observable.h" + +// QTabBar: tabs are closable when setTabsClosable(true) +class TabBar { +public: + TabBar() = default; + int addTab(const std::string &text); + int count() const { return (int)tabs_.size(); } + void setTabText(int index, const std::string &text) { if (index >= 0 && index < count()) tabs_[index].text = text; } + const std::string &tabText(int index) const { return tabs_[index].text; } + void setTabToolTip(int index, const std::string &tip) { if (index >= 0 && index < count()) tabs_[index].tooltip = tip; } + void setTabData(int index, int data) { if (index >= 0 && index < count()) tabs_[index].data = data; } + int tabData(int index) const { return index >= 0 && index < count() ? tabs_[index].data : 0; } + int currentIndex() const { return current_index_; } + void setCurrentIndex(int index); + int tabAt(const ImVec2 &pos) const; // -1 when no tab covers pos + void removeTab(int index); + void moveTab(int from, int to); + void setAutoHide(bool hide) { auto_hide_ = hide; } + void setTabsClosable(bool closable) { tabs_closable_ = closable; } // off by default + void setUsesScrollButtons(bool use) { scroll_buttons_ = use; } + void draw(); + + Observable currentChanged; + Observable tabCloseRequested; + Observable tabContextMenu; // emitted while drawing, right after the tab: open a context popup from it + +private: + struct Tab { std::string text; int data = 0; int id = 0; std::string tooltip; ImRect rect; }; + std::vector tabs_; + int current_index_ = -1; + int next_id_ = 0; + bool select_current_ = false; // programmatic current change, applied at the next draw() + bool auto_hide_ = false; + bool tabs_closable_ = false; + bool scroll_buttons_ = false; +}; diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc new file mode 100644 index 0000000000..fa6b9c7e6c --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -0,0 +1,612 @@ +#include "tools/cabana/ui/widgets/videowidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +} +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +const int MIN_VIDEO_HEIGHT = 100; +const int THUMBNAIL_MARGIN = 3; +const float POINT_10_FONT_SIZE = 13.0f; // 10 pt at 96 dpi +const float POINT_16_FONT_SIZE = 21.0f; // 16 pt at 96 dpi +const float TOOLBAR_MARGIN_Y = 6.0f; // between the slider and the buttons, which are as tall as the ones in the charts toolbar +const float TOOLBAR_SEPARATOR_EXTENT = 6.0f; +const float SLIDER_HEIGHT = 15.0f; // the handle plus a 1 px margin + +// Indexed by TimelineType: None, Engaged, AlertInfo, AlertWarning, AlertCritical, UserBookmark +static const ImU32 timeline_colors[] = { + IM_COL32(111, 143, 175, 255), + IM_COL32(0, 163, 108, 255), + IM_COL32(0, 255, 0, 255), + IM_COL32(255, 195, 0, 255), + IM_COL32(199, 0, 57, 255), + IM_COL32(255, 0, 255, 255), +}; + +static const float speeds[] = {0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.8, 1., 2., 3., 5.}; +static const int NORMAL_SPEED_INDEX = std::find(std::begin(speeds), std::end(speeds), 1.0f) - std::begin(speeds); + +static Replay *getReplay() { + auto stream = dynamic_cast(can); + return stream ? stream->getReplay() : nullptr; +} + +static std::string colorName(ImU32 c) { + char buf[16]; + snprintf(buf, sizeof(buf), "#%02x%02x%02x", (c >> IM_COL32_R_SHIFT) & 0xff, (c >> IM_COL32_G_SHIFT) & 0xff, (c >> IM_COL32_B_SHIFT) & 0xff); + return buf; +} + +// the zoomed range, or the whole route +static std::pair displayedTimeRange() { + return can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds())); +} + +// decode with libavcodec, already linked for the replay video decoder +static bool decodeJpeg(const uint8_t *data, size_t size, RgbImage *out) { + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_MJPEG); + AVCodecContext *context = codec ? avcodec_alloc_context3(codec) : nullptr; + AVFrame *frame = av_frame_alloc(); + AVPacket *packet = av_packet_alloc(); + bool ok = false; + if (context && frame && packet && size > 0 && size <= (size_t)INT32_MAX && av_new_packet(packet, (int)size) >= 0) { + std::copy(data, data + size, packet->data); + ok = avcodec_open2(context, codec, nullptr) >= 0 && avcodec_send_packet(context, packet) >= 0 && + avcodec_receive_frame(context, frame) >= 0 && frame->width > 0 && frame->height > 0; + } + int chroma_x_shift = 0, chroma_y_shift = 0; + if (ok) { + switch ((AVPixelFormat)frame->format) { + case AV_PIX_FMT_YUV420P: case AV_PIX_FMT_YUVJ420P: chroma_x_shift = 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: ok = false; break; + } + } + if (ok) { + out->resize(frame->width, frame->height); + 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; + const float y_scale = full_range ? 1.0f : 1.164383f; + const float y_offset = full_range ? 0.0f : 16.0f; + const float kr = full_range ? 1.402f : 1.596027f; + const float kgu = full_range ? 0.344136f : 0.391762f; + const float kgv = full_range ? 0.714136f : 0.812968f; + const float kb = full_range ? 1.772f : 2.017232f; + for (int y = 0; y < frame->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 *dst = out->data.data() + (size_t)y * out->bytesPerLine(); + for (int x = 0; x < frame->width; ++x) { + const float luma = y_scale * ((float)y_row[x] - y_offset); + const float u = (float)u_row[x >> chroma_x_shift] - 128.0f; + const float v = (float)v_row[x >> chroma_x_shift] - 128.0f; + const float r = luma + kr * v; + const float g = luma - kgu * u - kgv * v; + const float b = luma + kb * u; + dst[x * 4 + 0] = (uint8_t)std::clamp(std::lround(r), 0L, 255L); + dst[x * 4 + 1] = (uint8_t)std::clamp(std::lround(g), 0L, 255L); + dst[x * 4 + 2] = (uint8_t)std::clamp(std::lround(b), 0L, 255L); + dst[x * 4 + 3] = 255; + } + } + } + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return ok; +} + +VideoWidget::VideoWidget() { + if (!can->liveStreaming()) + createCameraWidget(); + + createSpeedDropdown(); + + connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { timeRangeChanged(); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { msgs_received_ = true; })); +} + +std::string VideoWidget::whatsThis() const { + // one
separated line per legend row, with the same entries and colors + return "Video
\n" + "Timeline color
\n" + + colorName(timeline_colors[(int)TimelineType::None]) + " Disengaged   " + + colorName(timeline_colors[(int)TimelineType::Engaged]) + " Engaged
\n" + + colorName(timeline_colors[(int)TimelineType::UserBookmark]) + " User Flag   " + + colorName(timeline_colors[(int)TimelineType::AlertInfo]) + " Info
\n" + + colorName(timeline_colors[(int)TimelineType::AlertWarning]) + " Warning   " + + colorName(timeline_colors[(int)TimelineType::AlertCritical]) + " Critical
\n" + "Shortcuts
\n" + "Pause/Resume:  space "; +} + +static float toolbarHeight() { return TOOLBAR_MARGIN_Y + ImGui::GetFrameHeight(); } + +void VideoWidget::drawPlaybackController() { + beginToolbar(); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + TOOLBAR_MARGIN_Y); + const float speed_width = menuButtonWidth("0.05x ", true); + + const char *play_icon = can->isPaused() ? icon::PLAY : icon::PAUSE; + const char *play_tooltip = can->isPaused() ? "Play" : "Pause"; + const char *loop_icon = getReplay() && getReplay()->loop() ? icon::REPEAT : icon::REPEAT_1; + const std::string time_text = slider_ ? formatTime(can->currentSec(), true) + " / " + formatTime(slider_->maximum() / slider_->factor) + : formatTime(can->currentSec(), true); + const char *time_tooltip = settings.absolute_time ? "Elapsed time" : "Absolute time"; + + auto seek_backward = []() { can->seekTo(can->currentSec() - 1); }; + auto toggle_play = []() { can->pause(!can->isPaused()); }; + auto seek_forward = []() { can->seekTo(can->currentSec() + 1); }; + + std::vector items = { + {toolbarButtonWidth(icon::REWIND), [&]() { if (toolButton("rewind", icon::REWIND, "Seek backward")) seek_backward(); }, + "Seek backward", seek_backward}, + {toolbarButtonWidth(play_icon), [&]() { if (toolButton("play", play_icon, play_tooltip)) toggle_play(); }, + play_tooltip, toggle_play}, + {toolbarButtonWidth(icon::FAST_FORWARD), [&]() { if (toolButton("fast-forward", icon::FAST_FORWARD, "Seek forward")) seek_forward(); }, + "Seek forward", seek_forward}, + }; + if (can->liveStreaming()) { + items.push_back({toolbarButtonWidth(icon::SKIP_END), [&]() { + ImGui::BeginDisabled(!skip_to_end_enabled_); + if (toolButton("skip-end", icon::SKIP_END, "Skip to the end")) skipToEnd(); + ImGui::EndDisabled(); + }, "Skip to the end", [this]() { skipToEnd(); }, skip_to_end_enabled_}); + } + if (slider_ || msgs_received_) { + // a mono font: with proportional digits the time changed width as it ticked and the items after it moved + pushMonoFont(ImGui::GetFontSize()); + const float time_width = toolbarButtonWidth(time_text); + popMonoFont(); + items.push_back({time_width, + [&]() { + pushMonoFont(ImGui::GetFontSize()); + if (toolButton("time_display", time_text.c_str(), time_tooltip)) toggleTimeDisplay(); + popMonoFont(); + }, + time_text, [this]() { toggleTimeDisplay(); }}); + } + // the expanding spacer: the items after it are right aligned as long as everything fits + const size_t spacer_index = items.size(); + if (!can->liveStreaming()) { + items.push_back({toolbarButtonWidth(loop_icon), [&]() { if (toolButton("loop", loop_icon, "Loop playback")) loopPlaybackClicked(); }, + "Loop playback", [this]() { loopPlaybackClicked(); }}); + } + items.push_back({speed_width, [&]() { drawSpeedDropdown(speed_width); }}); + if (!can->liveStreaming()) { + ToolbarItem separator{TOOLBAR_SEPARATOR_EXTENT, []() { + // a 1 px separator line centered in TOOLBAR_SEPARATOR_EXTENT, inset from the top and bottom + const ImVec2 min = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(TOOLBAR_SEPARATOR_EXTENT, ImGui::GetFrameHeight())); + const float x = std::floor(min.x + TOOLBAR_SEPARATOR_EXTENT * 0.5f); + ImGui::GetWindowDrawList()->AddLine(ImVec2(x, min.y + 4.0f), ImVec2(x, min.y + ImGui::GetFrameHeight() - 4.0f), ImGui::GetColorU32(ImGuiCol_Separator)); + }}; + separator.in_menu = false; + items.push_back(std::move(separator)); + items.push_back({toolbarButtonWidth(icon::INFO_CIRCLE), + [&]() { if (toolButton("route_info", icon::INFO_CIRCLE, "View route details")) showRouteInfo(); }, + "View route details", [this]() { showRouteInfo(); }}); + } + + drawToolbar(items, spacer_index); + endToolbar(); +} + +void VideoWidget::skipToEnd() { + // set speed to 1.0; this only checks the menu entry, the speed and the button text are unchanged + speed_index_ = NORMAL_SPEED_INDEX; + can->pause(false); + can->seekTo(can->maxSeconds() + 1); +} + +void VideoWidget::toggleTimeDisplay() { + settings.absolute_time = !settings.absolute_time; +} + +static std::string speedText(float speed, const char *suffix) { + char buf[32]; + snprintf(buf, sizeof(buf), "%gx%s", speed, suffix); + return buf; +} + +void VideoWidget::createSpeedDropdown() { + speed_index_ = NORMAL_SPEED_INDEX; + can->setSpeed(speeds[speed_index_]); + speed_text_ = speedText(speeds[speed_index_], " "); +} + +void VideoWidget::drawSpeedDropdown(float width) { + menuButton("speed_btn", speed_text_, "speed_menu", true, width); + if (ImGui::BeginPopup("speed_menu")) { + drawSpeedMenuItems(); + ImGui::EndPopup(); + } +} + +void VideoWidget::drawSpeedMenuItems() { + // every row declares the same width, so the popup is exactly as wide as the widest one and all the + // highlights reach both edges; the label is padded on the right as much as the check column on the left + const float indent = ImGui::GetFontSize(); + float label_width = 0; + for (int i = 0; i < (int)std::size(speeds); ++i) { + label_width = std::max(label_width, ImGui::CalcTextSize(speedText(speeds[i], "").c_str()).x); + } + for (int i = 0; i < (int)std::size(speeds); ++i) { + const float speed = speeds[i]; + if (radioMenuItem(speedText(speed, "").c_str(), speed_index_ == i, indent + label_width + indent)) { + speed_index_ = i; + can->setSpeed(speed); + speed_text_ = speedText(speed, " "); + } + } +} + +void VideoWidget::createCameraWidget() { + camera_tab_ = std::make_unique(); + camera_tab_->setAutoHide(true); + + cam_widget_ = std::make_unique("camerad", VISION_STREAM_NARROW_ROAD); + + slider_ = std::make_unique(); + slider_->setTimeRange(can->minSeconds(), can->maxSeconds()); + + connections_.push_back(slider_->sliderReleased.connect([this]() { can->seekTo(slider_->currentSecond()); })); + connections_.push_back(cam_widget_->clicked.connect([]() { can->pause(!can->isPaused()); })); + connections_.push_back(cam_widget_->availableStreamsUpdated.connect([this](std::set streams) { vipcAvailableStreamsUpdated(streams); })); + connections_.push_back(camera_tab_->currentChanged.connect([this](int index) { + if (index != -1) cam_widget_->setStreamType((VisionStreamType)camera_tab_->tabData(index)); + })); + connections_.push_back(static_cast(can)->qLogLoaded.connect([this](std::shared_ptr qlog) { cam_widget_->parseQLog(qlog); })); +} + +void VideoWidget::drawCameraWidget() { + camera_tab_->draw(); + + // cam_widget_: minimum height MIN_VIDEO_HEIGHT, takes the space left by the slider and the toolbar + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, avail.y - SLIDER_HEIGHT - toolbarHeight()); + cam_widget_->draw(ImVec2(avail.x, cam_height), thumbnail_display_time_); + + if (!slider_->isSliderDown()) slider_->setCurrentSecond(can->currentSec()); + slider_->draw(thumbnail_display_time_); + updateSliderThumbnail(); +} + +void VideoWidget::vipcAvailableStreamsUpdated(std::set streams) { + static const std::string stream_names[] = {"Road camera", "Driver camera", "Wide road camera"}; + for (int i = 0; i < streams.size(); ++i) { + if (camera_tab_->count() <= i) { + camera_tab_->addTab(std::string()); + } + int type = *std::next(streams.begin(), i); + camera_tab_->setTabText(i, stream_names[type]); + camera_tab_->setTabData(i, type); + } + while (camera_tab_->count() > streams.size()) { + camera_tab_->removeTab(camera_tab_->count() - 1); + } +} + +void VideoWidget::loopPlaybackClicked() { + getReplay()->setLoop(!getReplay()->loop()); +} + +void VideoWidget::timeRangeChanged() { + const auto time_range = can->timeRange(); + if (can->liveStreaming()) { + skip_to_end_enabled_ = !time_range.has_value(); + return; + } + time_range ? slider_->setTimeRange(time_range->first, time_range->second) + : slider_->setTimeRange(can->minSeconds(), can->maxSeconds()); +} + +std::string VideoWidget::formatTime(double sec, bool include_milliseconds) { + if (settings.absolute_time) + sec += std::chrono::duration(can->beginDateTime().time_since_epoch()).count(); + return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time); +} + +void VideoWidget::setVisible(bool visible) { + if (cam_widget_) cam_widget_->setVisible(visible); +} + +void VideoWidget::showThumbnail(double seconds) { + if (can->liveStreaming()) return; + thumbnail_display_time_ = seconds; +} + +void VideoWidget::showRouteInfo() { + // dropped from route_info_dlgs_ once draw() returns false + route_info_dlgs_.push_back(std::make_unique()); +} + +void VideoWidget::updateSliderThumbnail() { + if (slider_->underMouse()) { + auto [min_sec, max_sec] = displayedTimeRange(); + showThumbnail(min_sec + (ImGui::GetMousePos().x - slider_->rect().Min.x) * (max_sec - min_sec) / slider_->width()); + } else if (slider_->mouseLeft()) { + showThumbnail(-1); + } +} + +float VideoWidget::sizeHintHeight() const { + // the camera minimum height plus the slider and the toolbar + return MIN_VIDEO_HEIGHT + SLIDER_HEIGHT + toolbarHeight(); +} + +// the video pane opens with the camera at its natural aspect ratio, filling the width of the dock +float VideoWidget::defaultHeight(float width) const { + if (!cam_widget_) return toolbarHeight(); // live streams have no camera or slider + const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, width / cam_widget_->frameAspectRatio()); + const float tab_height = camera_tab_->count() >= 2 ? ImGui::GetFrameHeight() : 0.0f; + return cam_height + tab_height + SLIDER_HEIGHT + toolbarHeight(); +} + +void VideoWidget::draw() { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); + if (!can->liveStreaming()) + drawCameraWidget(); + + drawPlaybackController(); + ImGui::PopStyleVar(); + + for (auto it = route_info_dlgs_.begin(); it != route_info_dlgs_.end();) { + it = (*it)->draw() ? it + 1 : route_info_dlgs_.erase(it); + } +} + +void Slider::draw(double thumbnail_time) { + ImGui::InvisibleButton("##slider", ImVec2(std::max(1.0f, ImGui::GetContentRegionAvail().x), SLIDER_HEIGHT)); + rect_ = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + const bool hovered = ImGui::IsItemHovered(); + left_ = hovered_ && !hovered; + hovered_ = hovered; + + if (ImGui::IsItemActivated()) handleMousePress(); + if (slider_down_) { + if (ImGui::IsItemActive()) { + // the handle keeps its grab offset while dragging + setValue(pixelPosToRangeValue(ImGui::GetMousePos().x - click_offset_)); + } else { + slider_down_ = false; + sliderReleased(); + } + } + paint(thumbnail_time); +} + +ImRect Slider::handleRect() const { + const float handle_width = SLIDER_LENGTH; + const float handle_height = std::min(SLIDER_THICKNESS, rect_.GetHeight()); + const int range = std::max(1, maximum() - minimum()); + const float x = rect_.Min.x + (float)(value() - minimum()) / range * std::max(0.0f, width() - handle_width); + const float y = rect_.GetCenter().y - handle_height / 2; + return ImRect(ImVec2(x, y), ImVec2(x + handle_width, y + handle_height)); +} + +// handle left edge (window x) -> value over the groove minus the handle width +int Slider::pixelPosToRangeValue(float x) const { + const float handle_width = SLIDER_LENGTH; + const float span = std::max(1.0f, width() - handle_width); + return minimum() + (int)std::lround((maximum() - minimum()) * std::clamp((x - rect_.Min.x) / span, 0.0f, 1.0f)); +} + +void Slider::paint(double thumbnail_time) { + ImDrawList *p = ImGui::GetWindowDrawList(); + + ImRect handle_rect = handleRect(); + ImRect groove_rect = rect_; + + // adjust the groove height to match the handle height, rounded up to whole pixels + float handle_height = handle_rect.GetHeight(); + const float groove_height = std::ceil(handle_height * 0.5f); + const float center_y = rect_.GetCenter().y; + groove_rect.Min.y = std::floor(center_y - groove_height / 2); + groove_rect.Max.y = groove_rect.Min.y + groove_height; + + p->AddRectFilled(groove_rect.Min, groove_rect.Max, timeline_colors[(int)TimelineType::None]); + + double min = minimum() / factor; + double max = maximum() / factor; + const double span = std::max(max - min, 1e-9); + + auto fillRange = [&](double begin, double end, ImU32 color) { + if (begin > max || end < min) return; + + // the edges truncate to whole pixels and the right edge is inclusive, so even an event shorter than a + // pixel paints one full pixel in its color instead of an anti-aliased smear + ImRect r = groove_rect; + r.Min.x = rect_.Min.x + std::floor(((std::max(min, begin) - min) / span) * width()); + r.Max.x = rect_.Min.x + std::floor(((std::min(max, end) - min) / span) * width()) + 1.0f; + p->AddRectFilled(r.Min, r.Max, color); + }; + + if (auto replay = getReplay()) { + for (const auto &entry : *replay->getTimeline()) { + fillRange(entry.start_time, entry.end_time, timeline_colors[(int)entry.type]); + } + + ImU32 empty_color = ImGui::GetColorU32(ImGuiCol_WindowBg, 160 / 255.0f); + const auto event_data = replay->getEventData(); + for (const auto &[n, _] : replay->route().segments()) { + if (!event_data->isSegmentLoaded(n)) + fillRange(n * 60.0, (n + 1) * 60.0, empty_color); + } + } + + drawSliderHandle(p, handle_rect); + + if (thumbnail_time >= 0) { + float left = rect_.Min.x + (float)((thumbnail_time - min) * width() / span) - 1; + ImRect rc(ImVec2(left, rect_.Min.y + 1), ImVec2(left + 2, rect_.Max.y - 1)); + p->AddRectFilled(rc.Min, rc.Max, ImGui::GetColorU32(ImGuiCol_Header), 1.5f); // ImGuiCol_Header is the theme highlight + } +} + +void Slider::handleMousePress() { + // a press on the handle starts a drag and remembers the grab offset + const ImRect handle_rect = handleRect(); + if (handle_rect.Contains(ImGui::GetMousePos())) { + slider_down_ = true; + click_offset_ = ImGui::GetMousePos().x - handle_rect.Min.x; + return; + } + setValue(minimum() + (int)(((maximum() - minimum()) * (ImGui::GetMousePos().x - rect_.Min.x)) / width())); + sliderReleased(); +} + +StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type) + : CameraWidget(stream_name, stream_type) { + big_thumbnail_texture_.mipmap = true; // the hover thumbnail is drawn at a quarter of the stored size +} + +StreamCameraView::~StreamCameraView() { + for (auto &pending : pending_thumbnails_) pending.done.wait(); +} + +void StreamCameraView::parseQLog(std::shared_ptr qlog) { + auto thumbnails = std::make_shared>(); + auto done = ThreadPool::instance().run([qlog, thumbnails]() { + for (const Event &e : qlog->events) { + if (e.which != cereal::Event::Which::THUMBNAIL) continue; + capnp::FlatArrayMessageReader reader(e.data); + auto thumb_data = reader.getRoot().getThumbnail(); + auto image_data = thumb_data.getThumbnail(); + if (RgbImage thumb; decodeJpeg(image_data.begin(), image_data.size(), &thumb)) { + (*thumbnails)[thumb_data.getTimestampEof()] = std::move(thumb); + } + } + }); + pending_thumbnails_.push_back({std::move(done), std::move(thumbnails)}); +} + +void StreamCameraView::collectThumbnails() { + for (auto it = pending_thumbnails_.begin(); it != pending_thumbnails_.end();) { + if (it->done.wait_for(std::chrono::seconds(0)) != std::future_status::ready) { + ++it; + continue; + } + for (auto &[ts, thumb] : *it->thumbnails) big_thumbnails_[ts] = std::move(thumb); + it = pending_thumbnails_.erase(it); + } +} + +void StreamCameraView::draw(const ImVec2 &size, double thumbnail_time) { + collectThumbnails(); + CameraWidget::draw(size); + + ImDrawList *p = ImGui::GetWindowDrawList(); + bool scrubbing = false; + if (thumbnail_time >= 0) { + scrubbing = can->isPaused(); + scrubbing ? drawScrubThumbnail(p, thumbnail_time) : drawThumbnail(p, thumbnail_time); + } + if (auto alert = getReplay()->findAlertAtTime(scrubbing ? thumbnail_time : can->currentSec())) { + drawAlert(p, rect(), *alert, ImGui::GetFontSize()); + } + + if (can->isPaused()) { + ImFont *font = boldFont(); + const char *text = "PAUSED"; + const ImVec2 text_size = font->CalcTextSizeA(POINT_16_FONT_SIZE, FLT_MAX, 0.0f, text); + const ImVec2 center = rect().GetCenter(); + p->AddText(font, POINT_16_FONT_SIZE, ImVec2(center.x - text_size.x / 2, center.y - text_size.y / 2), + IM_COL32(200, 200, 200, static_cast(255 * 0.7f)), text); + } +} + +const RgbImage *StreamCameraView::thumbnailAt(double sec, uint64_t *mono_time) { + auto it = big_thumbnails_.lower_bound(can->toMonoTime(sec)); + if (it == big_thumbnails_.end()) return nullptr; + if (big_thumbnail_texture_.id == 0 || big_thumbnail_texture_.key != it->first) { + big_thumbnail_texture_.upload(it->second); + big_thumbnail_texture_.key = it->first; + } + if (mono_time) *mono_time = it->first; + return &it->second; +} + +void StreamCameraView::drawScrubThumbnail(ImDrawList *p, double sec) { + p->AddRectFilled(rect().Min, rect().Max, IM_COL32(0, 0, 0, 255)); + if (const RgbImage *image = thumbnailAt(sec, nullptr)) { + // scale to the widget size, keeping the aspect ratio + const float scale = std::min(width() / image->width, height() / image->height); + const ImVec2 scaled_size(std::floor(image->width * scale), std::floor(image->height * scale)); + const ImVec2 center = rect().GetCenter(); + const ImVec2 thumb_min(center.x - (int)(scaled_size.x / 2), center.y - (int)(scaled_size.y / 2)); + ImRect thumb_rect(thumb_min, ImVec2(thumb_min.x + scaled_size.x, thumb_min.y + scaled_size.y)); + p->AddImage(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max); + drawTime(p, thumb_rect, sec); + } +} + +void StreamCameraView::drawThumbnail(ImDrawList *p, double sec) { + uint64_t mono_time = 0; + if (const RgbImage *image = thumbnailAt(sec, &mono_time)) { + // AddImage scales the stored image to the thumbnail height, keeping the aspect ratio + const int h = MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2; + const int w = std::max(1, (int)std::lround((double)image->width * h / image->height)); + auto [min_sec, max_sec] = displayedTimeRange(); + int pos = (sec - min_sec) * width() / (max_sec - min_sec); + const int max_x = (int)width() - w - THUMBNAIL_MARGIN + 1; + int x = std::clamp(pos - w / 2, THUMBNAIL_MARGIN, std::max(THUMBNAIL_MARGIN, max_x)); + int y = height() - h - THUMBNAIL_MARGIN; + + ImRect thumb_rect(ImVec2(rect().Min.x + x, rect().Min.y + y), ImVec2(rect().Min.x + x + w, rect().Min.y + y + h)); + p->AddImage(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max); + p->AddRect(thumb_rect.Min, thumb_rect.Max, paletteBrightText(), 0.0f, 0, 2.0f); + if (auto alert = getReplay()->findAlertAtTime(can->toSeconds(mono_time))) { + drawAlert(p, thumb_rect, *alert, POINT_10_FONT_SIZE); + } + drawTime(p, thumb_rect, sec); + } +} + +void StreamCameraView::drawTime(ImDrawList *p, const ImRect &rect, double seconds) { + char text[32]; + snprintf(text, sizeof(text), "%.3f", seconds); + ImFont *font = ImGui::GetFont(); + const ImVec2 text_size = font->CalcTextSizeA(POINT_10_FONT_SIZE, FLT_MAX, 0.0f, text); + // centered horizontally, above the bottom margin + p->AddText(font, POINT_10_FONT_SIZE, ImVec2(rect.GetCenter().x - text_size.x / 2, rect.Max.y - THUMBNAIL_MARGIN - text_size.y), + paletteBrightText(), text); +} + +void StreamCameraView::drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size) { + const ImU32 pen = paletteBrightText(); + ImU32 color = withAlpha(timeline_colors[int(alert.type)], 128); + std::string text = alert.text1; + if (!alert.text2.empty()) text += "\n" + alert.text2; + + ImRect text_rect(ImVec2(rect.Min.x + 1, rect.Min.y + 1), ImVec2(rect.Max.x - 1, rect.Max.y - 1)); + ImFont *font = ImGui::GetFont(); + const float wrap_width = std::max(1.0f, text_rect.GetWidth()); + const ImVec2 r = font->CalcTextSizeA(font_size, FLT_MAX, wrap_width, text.c_str()); + p->AddRectFilled(ImVec2(text_rect.Min.x, text_rect.Min.y), ImVec2(text_rect.Max.x, text_rect.Min.y + r.y), color); + // each line is centered, wrapped continuations stay left aligned + float y = text_rect.Min.y; + for (const auto &line : utils::split(text, '\n')) { + const ImVec2 line_size = font->CalcTextSizeA(font_size, FLT_MAX, wrap_width, line.c_str()); + p->AddText(font, font_size, ImVec2(text_rect.Min.x + (text_rect.GetWidth() - line_size.x) / 2, y), pen, line.c_str(), nullptr, wrap_width); + y += line_size.y; + } +} diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h new file mode 100644 index 0000000000..44c3cd5312 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -0,0 +1,121 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/ui/widgets/cameraview.h" +#include "tools/cabana/ui/widgets/tabbar.h" +#include "tools/cabana/ui/tools/routeinfo.h" +#include "tools/replay/logreader.h" +#include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/ui/icons.h" + +class Slider { +public: + Slider() = default; + double currentSecond() const { return value() / factor; } + void setCurrentSecond(double sec) { setValue(sec * factor); } + void setTimeRange(double min, double max) { setRange(min * factor, max * factor); } + int value() const { return value_; } + void setValue(int v) { value_ = std::clamp(v, minimum_, maximum_); } + void setRange(int min, int max) { minimum_ = min; maximum_ = std::max(min, max); setValue(value_); } + int minimum() const { return minimum_; } + int maximum() const { return maximum_; } + bool isSliderDown() const { return slider_down_; } + float width() const { return rect_.GetWidth(); } + const ImRect &rect() const { return rect_; } + bool underMouse() const { return hovered_; } + bool mouseLeft() const { return left_; } // the mouse left the slider in the last draw() + void draw(double thumbnail_time); // thumbnail_time < 0: no thumbnail marker + static constexpr double factor = 1000.0; + + Observable<> sliderReleased; + +private: + void handleMousePress(); + void paint(double thumbnail_time); + ImRect handleRect() const; + int pixelPosToRangeValue(float x) const; + int minimum_ = 0; + int maximum_ = 99; + int value_ = 0; + bool slider_down_ = false; + float click_offset_ = 0; // where inside the handle the drag started + bool hovered_ = false; + bool left_ = false; + ImRect rect_; +}; + +class StreamCameraView : public CameraWidget { +public: + StreamCameraView(std::string stream_name, VisionStreamType stream_type); + ~StreamCameraView(); + void draw(const ImVec2 &size, double thumbnail_time); // thumbnail_time < 0: no thumbnail + void parseQLog(std::shared_ptr qlog); // decodes the thumbnails on the thread pool + +private: + struct PendingThumbnails { + std::future done; + std::shared_ptr> thumbnails; + }; + void collectThumbnails(); // moves the decoded thumbnails in once a parseQLog task is done + // the first thumbnail at or after sec, uploaded to big_thumbnail_texture_; nullptr when there is none + const RgbImage *thumbnailAt(double sec, uint64_t *mono_time); + void drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size); + void drawThumbnail(ImDrawList *p, double sec); + void drawScrubThumbnail(ImDrawList *p, double sec); + void drawTime(ImDrawList *p, const ImRect &rect, double seconds); + + std::map big_thumbnails_; + GlTexture big_thumbnail_texture_; // the currently shown thumbnail + std::vector pending_thumbnails_; +}; + +class VideoWidget { +public: + VideoWidget(); + void draw(); // content only; MainWindow puts it in a child region above the charts + float sizeHintHeight() const; + float defaultHeight(float width) const; + // MainWindow calls this every frame with the video dock visibility, so the camera widget gets its + // vipc thread started and stopped + void setVisible(bool visible); + void showThumbnail(double seconds); + std::string whatsThis() const; + +private: + void updateSliderThumbnail(); // the thumbnail follows the mouse over the slider + std::string formatTime(double sec, bool include_milliseconds = false); + void timeRangeChanged(); + void createCameraWidget(); + void drawCameraWidget(); + void drawPlaybackController(); + void skipToEnd(); + void toggleTimeDisplay(); + void createSpeedDropdown(); + void drawSpeedDropdown(float width); + void drawSpeedMenuItems(); + void loopPlaybackClicked(); + void vipcAvailableStreamsUpdated(std::set streams); + void showRouteInfo(); + + std::unique_ptr cam_widget_; + std::string speed_text_; + int speed_index_ = -1; // checked entry of the speed menu + bool skip_to_end_enabled_ = true; + bool msgs_received_ = false; // the time is blank until the live stream delivers its first messages + double thumbnail_display_time_ = -1; + std::unique_ptr slider_; + std::unique_ptr camera_tab_; + std::vector> route_info_dlgs_; + Connections connections_; // last: disconnected before the widgets its handlers dereference are destroyed +}; diff --git a/openpilot/tools/cabana/utils/strings.h b/openpilot/tools/cabana/utils/strings.h index f6581c3164..1432ea9257 100644 --- a/openpilot/tools/cabana/utils/strings.h +++ b/openpilot/tools/cabana/utils/strings.h @@ -1,7 +1,11 @@ #pragma once +#include +#include +#include #include #include +#include #include #include @@ -12,6 +16,78 @@ namespace utils { std::string formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false); std::string signalToolTip(const cabana::Signal *sig); +inline std::string trimmed(const std::string &s) { + const char *ws = " \t\n\r\f\v"; + size_t b = s.find_first_not_of(ws); + if (b == std::string::npos) return ""; + return s.substr(b, s.find_last_not_of(ws) - b + 1); +} + +inline bool containsCI(const std::string &s, const std::string &txt) { + auto it = std::search(s.begin(), s.end(), txt.begin(), txt.end(), + [](unsigned char a, unsigned char b) { return std::tolower(a) == std::tolower(b); }); + return it != s.end(); +} + +// drop the tags of a rich text tooltip for the plain text imgui tooltip +inline std::string stripHtml(const std::string &s) { + std::string out; + bool in_tag = false; + for (char c : s) { + if (c == '<') in_tag = true; + else if (c == '>') in_tag = false; + else if (!in_tag) out += c; + } + return trimmed(out); +} + +inline std::vector split(const std::string &s, char sep) { + std::vector parts; + size_t start = 0; + for (size_t pos; (pos = s.find(sep, start)) != std::string::npos; start = pos + 1) { + parts.push_back(s.substr(start, pos - start)); + } + parts.push_back(s.substr(start)); + return parts; +} + +inline std::string toString(double v) { + char buf[32]; + snprintf(buf, sizeof(buf), "%g", v); + return buf; +} + +// 0 when the text is not a valid number +inline double toDouble(const std::string &s) { + char *end = nullptr; + double v = std::strtod(s.c_str(), &end); + return (end != s.c_str() && *end == '\0') ? v : 0.0; +} + +// 0 when the text is not fully consumed +inline int toInt(const std::string &s) { + char *end = nullptr; + long v = std::strtol(s.c_str(), &end, 10); + return (end != s.c_str() && *end == '\0') ? (int)v : 0; +} + +// 0 when the text is not fully consumed +inline unsigned long toULong(const std::string &s, int base = 10) { + char *end = nullptr; + unsigned long v = std::strtoul(s.c_str(), &end, base); + return (end != s.c_str() && *end == '\0') ? v : 0; +} + +// "00".."FF" +inline const char *hexByte(uint8_t value) { + static const auto table = [] { + std::array t; + for (int i = 0; i < 256; ++i) snprintf(t[i], sizeof(t[i]), "%02X", i); + return t; + }(); + return table[value]; +} + inline std::string toHex(const std::vector &dat, char separator = '\0') { static const char digits[] = "0123456789ABCDEF"; std::string hex; diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index 324c966b5a..b2d53c415f 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -90,6 +91,14 @@ std::string bootstrapSvg(const std::string &id); // empty if unknown template std::vector toBytes(const T &dat) { return {dat.begin(), dat.end()}; } +// a callback that is skipped once `alive` expired: the owner resets its token when it goes away +template +auto guarded(const std::shared_ptr &alive, F fn) { + return [alive = std::weak_ptr(alive), fn = std::move(fn)](auto &&...args) { + if (!alive.expired()) fn(std::forward(args)...); + }; +} + } // Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread. diff --git a/tools/op.sh b/tools/op.sh index 3d7d17a76b..43b28e34c1 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -445,7 +445,7 @@ function op_default() { echo -e "${BOLD}${UNDERLINE}Commands [Tooling]:${NC}" echo -e " ${BOLD}juggle${NC} Run PlotJuggler" echo -e " ${BOLD}replay${NC} Run Replay" - echo -e " ${BOLD}cabana${NC} Run Cabana" + echo -e " ${BOLD}cabana${NC} Run Cabana (--legacy for the old Qt version)" echo -e " ${BOLD}clip${NC} Run clip (linux only)" echo -e " ${BOLD}adb${NC} Run adb shell" echo -e " ${BOLD}ssh${NC} comma prime SSH helper" From c163743e32c8ed6408c6cad28f8f41fcbbf1b14d Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:20:23 -0700 Subject: [PATCH 003/270] cabana: better colors (#38758) --- openpilot/tools/cabana/ui/chart/sparkline.cc | 9 +-- openpilot/tools/cabana/ui/chart/sparkline.h | 2 +- .../tools/cabana/ui/dialogs/streamselector.cc | 8 ++- openpilot/tools/cabana/ui/style.cc | 72 ++++++++++--------- openpilot/tools/cabana/ui/util.h | 1 + .../tools/cabana/ui/widgets/binaryview.cc | 11 +-- .../tools/cabana/ui/widgets/signalview.cc | 4 +- openpilot/tools/cabana/utils/util.cc | 1 - openpilot/tools/cabana/utils/util.h | 21 +++--- 9 files changed, 69 insertions(+), 60 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/sparkline.cc b/openpilot/tools/cabana/ui/chart/sparkline.cc index 226483fd04..e5fe87182d 100644 --- a/openpilot/tools/cabana/ui/chart/sparkline.cc +++ b/openpilot/tools/cabana/ui/chart/sparkline.cc @@ -108,8 +108,9 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi xscale_ = xscale; } -void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos) const { +void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos, ImU32 color) const { if (render_points_.empty()) return; + if (color == 0) color = color_; // update() only runs when a message of this id arrives, so a slow message would hold the sparkline // still for many frames and then move it in one step. scroll the rendered polyline by the time that @@ -132,14 +133,14 @@ void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos) const { draw_list->PushClipRect(pos, ImVec2(pos.x + size.x, pos.y + size.y), true); // a point is a 3x3 square - auto draw_point = [&](const ImVec2 &p) { draw_list->AddRectFilled(ImVec2(p.x - 1.5f, p.y - 1.5f), ImVec2(p.x + 1.5f, p.y + 1.5f), color_); }; + auto draw_point = [&](const ImVec2 &p) { draw_list->AddRectFilled(ImVec2(p.x - 1.5f, p.y - 1.5f), ImVec2(p.x + 1.5f, p.y + 1.5f), color); }; if (draw_individual_points_) { for (const auto &p : render_points_) { draw_list->PathLineTo(point_at(p)); draw_point(point_at(p)); } - draw_list->PathStroke(color_, ImDrawFlags_None, 1.5f); + draw_list->PathStroke(color, ImDrawFlags_None, 1.5f); } else { // one sample per pixel column: several strokes in a column overlap into a blur, and a dense // high-contrast texture scrolling by is hard on the eyes @@ -167,7 +168,7 @@ void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos) const { while (j + 1 < pts.size() && steep(j) == is_steep) ++j; draw_list->Flags = is_steep ? (saved & ~ImDrawListFlags_AntiAliasedLines) : saved; for (size_t n = i; n <= j; ++n) draw_list->PathLineTo(pts[n]); - draw_list->PathStroke(color_, ImDrawFlags_None, 1.0f); + draw_list->PathStroke(color, ImDrawFlags_None, 1.0f); i = j; } draw_list->Flags = saved; diff --git a/openpilot/tools/cabana/ui/chart/sparkline.h b/openpilot/tools/cabana/ui/chart/sparkline.h index bfee34c9ee..991ecb8d4c 100644 --- a/openpilot/tools/cabana/ui/chart/sparkline.h +++ b/openpilot/tools/cabana/ui/chart/sparkline.h @@ -12,7 +12,7 @@ public: inline double freq() const { return freq_; } bool isEmpty() const { return render_points_.empty(); } // emits the rendered polyline at pos (top-left, screen coordinates) - void draw(ImDrawList *draw_list, ImVec2 pos) const; + void draw(ImDrawList *draw_list, ImVec2 pos, ImU32 color = 0) const; ImVec2 size = {}; // empty when isEmpty() double min_val = 0; diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.cc b/openpilot/tools/cabana/ui/dialogs/streamselector.cc index 44336447c6..4c724c6fce 100644 --- a/openpilot/tools/cabana/ui/dialogs/streamselector.cc +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.cc @@ -263,16 +263,19 @@ void StreamSelector::open(Callback on_done) { void StreamSelector::draw() { if (!open_) return; - if (!beginDialog("Open stream", &popup_, ImVec2(640.0f, 0.0f))) return; + if (!beginDialog("Open stream", &popup_, ImVec2(768.0f, 0.0f))) return; AbstractOpenStreamWidget *current = nullptr; + const ImVec4 pane = ImGui::GetStyleColorVec4(ImGuiCol_WindowBg); + ImGui::PushStyleColor(ImGuiCol_ChildBg, pane); + ImGui::PushStyleColor(ImGuiCol_TabSelected, pane); if (ImGui::BeginTabBar("streams")) { for (auto &w : widgets_) { // a fresh dialog every time, so the first tab is always the current one ImGuiTabItemFlags tab_flags = (first_frame_ && w == widgets_.front()) ? ImGuiTabItemFlags_SetSelected : 0; if (ImGui::BeginTabItem(w->title(), nullptr, tab_flags)) { current = w.get(); - ImGui::BeginChild("tab", ImVec2(0, 130.0f)); + ImGui::BeginChild("tab", ImVec2(0, 130.0f), ImGuiChildFlags_Borders); w->draw(); ImGui::EndChild(); ImGui::EndTabItem(); @@ -280,6 +283,7 @@ void StreamSelector::draw() { } ImGui::EndTabBar(); } + ImGui::PopStyleColor(2); first_frame_ = false; ImGui::AlignTextToFramePadding(); diff --git a/openpilot/tools/cabana/ui/style.cc b/openpilot/tools/cabana/ui/style.cc index b1b5c6cee6..8cbedad837 100644 --- a/openpilot/tools/cabana/ui/style.cc +++ b/openpilot/tools/cabana/ui/style.cc @@ -86,46 +86,44 @@ void applyTheme(int theme) { auto c = [](const CabanaColor &col, float a = 1.0f) { return colorRgb(col.r, col.g, col.b, a); }; ImVec4 *colors = style.Colors; if (dark) { - // the low contrast Darcula grays are opened up: text and outlines sit further from the window and - // base grays const ImVec4 highlight = c(DarkTheme::highlight); - const ImVec4 outline = colorRgb(0x5a, 0x5d, 0x60); + const ImVec4 outline = colorRgb(0x26, 0x26, 0x26); colors[ImGuiCol_WindowBg] = c(DarkTheme::window); colors[ImGuiCol_ChildBg] = c(DarkTheme::base); - colors[ImGuiCol_PopupBg] = c(DarkTheme::base); + colors[ImGuiCol_PopupBg] = c(DarkTheme::window); colors[ImGuiCol_MenuBarBg] = c(DarkTheme::window); colors[ImGuiCol_DockingEmptyBg] = c(DarkTheme::window); - colors[ImGuiCol_Text] = colorRgb(0xdc, 0xdc, 0xdc); - colors[ImGuiCol_TextDisabled] = colorRgb(0x8c, 0x8c, 0x8c); + colors[ImGuiCol_Text] = c(DarkTheme::text); + colors[ImGuiCol_TextDisabled] = c(DarkTheme::disabled_text); colors[ImGuiCol_Border] = outline; colors[ImGuiCol_BorderShadow] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_FrameBg] = colorRgb(0x2e, 0x30, 0x32); // darker than the base so fields read as sunken - colors[ImGuiCol_FrameBgHovered] = colorRgb(0x3a, 0x3d, 0x40); - colors[ImGuiCol_FrameBgActive] = colorRgb(0x45, 0x48, 0x4b); - colors[ImGuiCol_Button] = c(DarkTheme::button); - colors[ImGuiCol_ButtonHovered] = colorRgb(0x52, 0x56, 0x59); - colors[ImGuiCol_ButtonActive] = colorRgb(0x2b, 0x2d, 0x30); + colors[ImGuiCol_FrameBg] = c(DarkTheme::base); + colors[ImGuiCol_FrameBgHovered] = colorRgb(0x1f, 0x1f, 0x1f); + colors[ImGuiCol_FrameBgActive] = colorRgb(0x24, 0x24, 0x24); + colors[ImGuiCol_Button] = colorRgb(0x3a, 0x3a, 0x3a); + colors[ImGuiCol_ButtonHovered] = colorRgb(0x42, 0x42, 0x42); + colors[ImGuiCol_ButtonActive] = colorRgb(0x30, 0x30, 0x30); colors[ImGuiCol_Header] = highlight; colors[ImGuiCol_HeaderHovered] = c(DarkTheme::highlight, 0.8f); colors[ImGuiCol_HeaderActive] = highlight; colors[ImGuiCol_CheckMark] = c(DarkTheme::bright_text); - colors[ImGuiCol_SliderGrab] = colorRgb(0x8f, 0x92, 0x95); - colors[ImGuiCol_SliderGrabActive] = colorRgb(0xa8, 0xab, 0xae); + colors[ImGuiCol_SliderGrab] = colorRgb(0x6a, 0x6a, 0x6a); + colors[ImGuiCol_SliderGrabActive] = colorRgb(0x80, 0x80, 0x80); colors[ImGuiCol_ScrollbarBg] = c(DarkTheme::window); - colors[ImGuiCol_ScrollbarGrab] = colorRgb(0x70, 0x73, 0x76); - colors[ImGuiCol_ScrollbarGrabHovered] = colorRgb(0x85, 0x88, 0x8b); - colors[ImGuiCol_ScrollbarGrabActive] = c(DarkTheme::light); + colors[ImGuiCol_ScrollbarGrab] = colorRgb(0x5a, 0x5a, 0x5a); + colors[ImGuiCol_ScrollbarGrabHovered] = colorRgb(0x6a, 0x6a, 0x6a); + colors[ImGuiCol_ScrollbarGrabActive] = colorRgb(0x7a, 0x7a, 0x7a); colors[ImGuiCol_Separator] = outline; colors[ImGuiCol_SeparatorHovered] = c(DarkTheme::highlight, 0.6f); colors[ImGuiCol_SeparatorActive] = highlight; colors[ImGuiCol_ResizeGrip] = colorRgb(0, 0, 0, 0.0f); colors[ImGuiCol_ResizeGripHovered] = c(DarkTheme::highlight, 0.6f); colors[ImGuiCol_ResizeGripActive] = highlight; - colors[ImGuiCol_Tab] = c(DarkTheme::window); - colors[ImGuiCol_TabHovered] = colorRgb(0x4b, 0x4e, 0x52); + colors[ImGuiCol_Tab] = colorRgb(0x2c, 0x2c, 0x2c); + colors[ImGuiCol_TabHovered] = colorRgb(0x3a, 0x3a, 0x3a); colors[ImGuiCol_TabSelected] = c(DarkTheme::base); colors[ImGuiCol_TabSelectedOverline] = highlight; - colors[ImGuiCol_TabDimmed] = c(DarkTheme::window); + colors[ImGuiCol_TabDimmed] = colorRgb(0x2c, 0x2c, 0x2c); colors[ImGuiCol_TabDimmedSelected] = c(DarkTheme::base); colors[ImGuiCol_TabDimmedSelectedOverline] = colorRgb(0, 0, 0, 0.0f); colors[ImGuiCol_TitleBg] = c(DarkTheme::window); @@ -133,7 +131,7 @@ void applyTheme(int theme) { colors[ImGuiCol_TitleBgCollapsed] = c(DarkTheme::window); colors[ImGuiCol_TableHeaderBg] = c(DarkTheme::window); colors[ImGuiCol_TableBorderStrong] = outline; - colors[ImGuiCol_TableBorderLight] = colorRgb(0x23, 0x26, 0x28); // darker than the cells, like the qt grid + colors[ImGuiCol_TableBorderLight] = colorRgb(0x2c, 0x2c, 0x2c); colors[ImGuiCol_TableRowBg] = colorRgb(0, 0, 0, 0.0f); colors[ImGuiCol_TableRowBgAlt] = colorRgb(0xff, 0xff, 0xff, 0.06f); colors[ImGuiCol_TextSelectedBg] = c(DarkTheme::highlight, 0.6f); @@ -145,11 +143,11 @@ void applyTheme(int theme) { } else { const ImVec4 window = colorRgb(0xef, 0xef, 0xef); const ImVec4 base = colorRgb(0xff, 0xff, 0xff); - const ImVec4 outline = colorRgb(0xb9, 0xb9, 0xb9); + const ImVec4 outline = colorRgb(0xab, 0xab, 0xab); const ImVec4 highlight = colorRgb(0x30, 0x8c, 0xc6); colors[ImGuiCol_WindowBg] = window; colors[ImGuiCol_ChildBg] = base; - colors[ImGuiCol_PopupBg] = colorRgb(0xfb, 0xfb, 0xfb); + colors[ImGuiCol_PopupBg] = colorRgb(0xf8, 0xf8, 0xf8); colors[ImGuiCol_MenuBarBg] = window; colors[ImGuiCol_DockingEmptyBg] = window; colors[ImGuiCol_Text] = colorRgb(0x00, 0x00, 0x00); @@ -159,9 +157,9 @@ void applyTheme(int theme) { colors[ImGuiCol_FrameBg] = base; colors[ImGuiCol_FrameBgHovered] = colorRgb(0xf7, 0xf7, 0xf7); colors[ImGuiCol_FrameBgActive] = colorRgb(0xef, 0xef, 0xef); - colors[ImGuiCol_Button] = colorRgb(0xf3, 0xf3, 0xf3); - colors[ImGuiCol_ButtonHovered] = colorRgb(0xf9, 0xf9, 0xf9); - colors[ImGuiCol_ButtonActive] = colorRgb(0xdc, 0xdc, 0xdc); + colors[ImGuiCol_Button] = colorRgb(0xf5, 0xf5, 0xf5); + colors[ImGuiCol_ButtonHovered] = colorRgb(0xfa, 0xfa, 0xfa); + colors[ImGuiCol_ButtonActive] = colorRgb(0xd9, 0xd9, 0xd9); colors[ImGuiCol_Header] = highlight; colors[ImGuiCol_HeaderHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.8f); colors[ImGuiCol_HeaderActive] = highlight; @@ -178,11 +176,11 @@ void applyTheme(int theme) { colors[ImGuiCol_ResizeGrip] = colorRgb(0, 0, 0, 0.0f); colors[ImGuiCol_ResizeGripHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.6f); colors[ImGuiCol_ResizeGripActive] = highlight; - colors[ImGuiCol_Tab] = colorRgb(0xe2, 0xe2, 0xe2); + colors[ImGuiCol_Tab] = colorRgb(0xdc, 0xdc, 0xdc); colors[ImGuiCol_TabHovered] = colorRgb(0xf5, 0xf5, 0xf5); colors[ImGuiCol_TabSelected] = base; colors[ImGuiCol_TabSelectedOverline] = highlight; - colors[ImGuiCol_TabDimmed] = colorRgb(0xe2, 0xe2, 0xe2); + colors[ImGuiCol_TabDimmed] = colorRgb(0xdc, 0xdc, 0xdc); colors[ImGuiCol_TabDimmedSelected] = base; colors[ImGuiCol_TabDimmedSelectedOverline] = colorRgb(0, 0, 0, 0.0f); colors[ImGuiCol_TitleBg] = window; @@ -190,7 +188,7 @@ void applyTheme(int theme) { colors[ImGuiCol_TitleBgCollapsed] = window; colors[ImGuiCol_TableHeaderBg] = colorRgb(0xf2, 0xf2, 0xf2); colors[ImGuiCol_TableBorderStrong] = outline; - colors[ImGuiCol_TableBorderLight] = colorRgb(0xd8, 0xd8, 0xd8); + colors[ImGuiCol_TableBorderLight] = colorRgb(0xc7, 0xc7, 0xc7); colors[ImGuiCol_TableRowBg] = colorRgb(0, 0, 0, 0.0f); colors[ImGuiCol_TableRowBgAlt] = colorRgb(0, 0, 0, 0.03f); colors[ImGuiCol_TextSelectedBg] = colorRgb(0x30, 0x8c, 0xc6, 0.35f); @@ -207,6 +205,12 @@ void applyTheme(int theme) { bool isDarkTheme() { return g_dark; } +CabanaColor signalFillColor(const CabanaColor &c) { + if (!g_dark) return c; + auto [h, s, v] = c.hsv(); + return CabanaColor::fromHsv(h, std::min(1.0f, s * 1.4f), v * 0.8f, c.a / 255.0f); +} + ImU32 highlightedTextColor() { return g_dark ? IM_COL32(DarkTheme::window_text.r, DarkTheme::window_text.g, DarkTheme::window_text.b, 255) : IM_COL32(255, 255, 255, 255); @@ -219,11 +223,11 @@ ImU32 paletteBrightText() { void drawSliderHandle(ImDrawList *p, const ImRect &r) { const bool dark = isDarkTheme(); - const ImU32 top = dark ? IM_COL32(0x3e, 0x41, 0x43, 255) : IM_COL32(255, 255, 255, 255); - const ImU32 bottom = dark ? IM_COL32(0x39, 0x3c, 0x3e, 255) : IM_COL32(0xf0, 0xf0, 0xf0, 255); + const ImU32 top = dark ? IM_COL32(0x41, 0x41, 0x41, 255) : IM_COL32(255, 255, 255, 255); + const ImU32 bottom = dark ? IM_COL32(0x36, 0x36, 0x36, 255) : IM_COL32(0xf0, 0xf0, 0xf0, 255); // the top/left edge is one step lighter than the bottom/right edge - const ImU32 outline_top = dark ? IM_COL32(0xa3, 0xa3, 0xa3, 255) : IM_COL32(0xab, 0xab, 0xab, 255); - const ImU32 outline_bottom = dark ? IM_COL32(0x9c, 0x9c, 0x9c, 255) : IM_COL32(0xa4, 0xa4, 0xa4, 255); + const ImU32 outline_top = dark ? IM_COL32(0x5c, 0x5c, 0x5c, 255) : IM_COL32(0xab, 0xab, 0xab, 255); + const ImU32 outline_bottom = dark ? IM_COL32(0x26, 0x26, 0x26, 255) : IM_COL32(0xa4, 0xa4, 0xa4, 255); p->AddRectFilled(r.Min, r.Max, top, 2.0f); p->AddRectFilled(ImVec2(r.Min.x, r.GetCenter().y), r.Max, bottom, 2.0f, ImDrawFlags_RoundCornersBottom); p->AddRect(r.Min, r.Max, outline_bottom, 2.0f, 0, 1.0f); @@ -237,7 +241,7 @@ void drawSliderHandle(ImDrawList *p, const ImRect &r) { bool fusionSliderInt(const char *label, int *v, int min, int max, float width) { // a grey groove over the full width with the part left of the handle filled, and a 13x13 handle on top - const ImU32 groove_col = isDarkTheme() ? IM_COL32(0x2a, 0x2c, 0x2e, 255) : IM_COL32(0xc4, 0xc4, 0xc4, 255); + const ImU32 groove_col = isDarkTheme() ? IM_COL32(0x2b, 0x2b, 0x2b, 255) : IM_COL32(0xc4, 0xc4, 0xc4, 255); const ImU32 fill_col = ImGui::GetColorU32(ImGuiCol_Header); ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32_BLACK_TRANS); ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32_BLACK_TRANS); diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index 99de047913..5739cfa3bc 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -137,6 +137,7 @@ void drawColorMarker(ImDrawList *dl, const ImVec2 &pos, ImU32 col); void loadFonts(); void applyTheme(int theme); // safe to call at runtime bool isDarkTheme(); // the theme applyTheme() resolved +CabanaColor signalFillColor(const CabanaColor &c); ImU32 highlightedTextColor(); ImU32 paletteBrightText(); diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index 87e7213ea8..7f9ac8b7b2 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -455,13 +455,14 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary if (item->sigs.size() > 0) { for (auto &s : item->sigs) { if (s == hovered_sig_) { - painter->AddRectFilled(rect.Min, rect.Max, toImU32(s->color.darker(125))); // 4/5x brightness + painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalFillColor(s->color).darker(125))); // 4/5x brightness } else { drawSignalCell(painter, rect, index, s); } } - } else if (item->valid && item->bg_color.alpha() > 0) { - painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + } else if (item->valid) { + if (isDarkTheme()) painter->AddRectFilled(rect.Min, rect.Max, IM_COL32(255, 255, 255, 20)); + if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); } bool bright = std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); pen = bright ? paletteBrightText() : paletteText(is_message_active_); @@ -524,9 +525,9 @@ void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const B if (bottom_notch) band(bottom_notch, rc.Max.y - spacing, rc.Max.y); auto item = &cellAt(index); - CabanaColor color = sig->color; + CabanaColor color = signalFillColor(sig->color); color.a = item->bg_color.alpha(); - const ImU32 edge = toImU32(sig->color.darker(125)); + const ImU32 edge = toImU32(signalFillColor(sig->color).darker(125)); for (const ImRect &clip : region) { painter->PushClipRect(clip.Min, clip.Max, true); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index af926b5bb0..1eff979b02 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -267,7 +267,7 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const if (item->type == SignalModel::Item::Sig) { // color label ImRect icon_rect(rect.Min.x, rect.Min.y, rect.Min.x + COLOR_LABEL_WIDTH, rect.Max.y); - painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(item->sig->color.darker(item->highlight ? 125 : 0)), 3.0f); + painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(signalFillColor(item->sig->color).darker(item->highlight ? 125 : 0)), 3.0f); drawText(painter, icon_rect, std::to_string(item->row() + 1).c_str(), item->highlight ? IM_COL32_WHITE : IM_COL32_BLACK, nullptr, LABEL_FONT); @@ -289,7 +289,7 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const } else if (column == 1) { if (!item->sparkline.isEmpty()) { const ImVec2 sparkline_size = item->sparkline.size; - item->sparkline.draw(painter, rect.Min); + item->sparkline.draw(painter, rect.Min, selected ? text_color : 0); // min-max value rect.Min.x += sparkline_size.x + 1; float value_adjust = 10; diff --git a/openpilot/tools/cabana/utils/util.cc b/openpilot/tools/cabana/utils/util.cc index 7bdf225975..1d1c8c75e4 100644 --- a/openpilot/tools/cabana/utils/util.cc +++ b/openpilot/tools/cabana/utils/util.cc @@ -242,7 +242,6 @@ static std::unordered_map load_bootstrap_icons() { } namespace utils { - std::string homePath() { const char *home = ::getenv("HOME"); return home ? home : ""; diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index b2d53c415f..203c504c44 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -60,19 +60,18 @@ ValidState validateIpAddress(const std::string &input); // C-locale floating-point ValidState validateDouble(const std::string &input); -// "Darcula" like dark theme struct DarkTheme { static constexpr CabanaColor window{0x35, 0x35, 0x35}; - static constexpr CabanaColor window_text{0xbb, 0xbb, 0xbb}; - static constexpr CabanaColor base{0x3c, 0x3f, 0x41}; - static constexpr CabanaColor tooltip_text{0xbb, 0xbb, 0xbb}; - static constexpr CabanaColor text{0xbb, 0xbb, 0xbb}; - static constexpr CabanaColor button{0x3c, 0x3f, 0x41}; - static constexpr CabanaColor highlight{0x2f, 0x65, 0xca}; - static constexpr CabanaColor bright_text{0xf0, 0xf0, 0xf0}; - static constexpr CabanaColor disabled_text{0x77, 0x77, 0x77}; - static constexpr CabanaColor light{0x77, 0x77, 0x77}; - static constexpr CabanaColor dark{0x35, 0x35, 0x35}; + static constexpr CabanaColor window_text{0xff, 0xff, 0xff}; + static constexpr CabanaColor base{0x19, 0x19, 0x19}; + static constexpr CabanaColor tooltip_text{0xff, 0xff, 0xff}; + static constexpr CabanaColor text{0xff, 0xff, 0xff}; + static constexpr CabanaColor button{0x35, 0x35, 0x35}; + static constexpr CabanaColor highlight{0x2a, 0x82, 0xda}; + static constexpr CabanaColor bright_text{0xff, 0xff, 0xff}; + static constexpr CabanaColor disabled_text{0x7f, 0x7f, 0x7f}; + static constexpr CabanaColor light{0x50, 0x50, 0x50}; + static constexpr CabanaColor dark{0x23, 0x23, 0x23}; }; namespace utils { From 7bade9a67a09b38c136a417a4197f1dfaa872588 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:28:53 -0700 Subject: [PATCH 004/270] cabana: halve dock panel min width (#38759) --- openpilot/tools/cabana/ui/mainwin.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 15b6ff8df8..4a0aef7c79 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -787,8 +787,8 @@ void MainWindow::drawDockspace() { ImGui::DockBuilderFinish(dock_id); reset_layout_ = false; } - // a panel never shrinks past the width where the signal view's tool bar squishes - const float min_panel_width = SignalView::minimumWidth() + (ImGui::GetStyle().WindowPadding.x + ImGui::GetStyle().WindowBorderSize) * 2; + // a panel never shrinks past half the width where the signal view's tool bar squishes + const float min_panel_width = (SignalView::minimumWidth() + (ImGui::GetStyle().WindowPadding.x + ImGui::GetStyle().WindowBorderSize) * 2) * 0.5f; ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(min_panel_width, ImGui::GetStyle().WindowMinSize.y)); ImGui::DockSpace(dock_id, dock_size); ImGui::PopStyleVar(); From 3a13b67b6f92c0716616342f285165a215ab7900 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:44:11 -0700 Subject: [PATCH 005/270] bye bye Qt! (#38753) * cabana: remove Qt implementation --- SConstruct | 1 - openpilot/tools/cabana/.gitignore | 7 - openpilot/tools/cabana/README.md | 40 +- openpilot/tools/cabana/SConscript | 123 +-- openpilot/tools/cabana/assets/assets.qrc | 5 - openpilot/tools/cabana/assets/cabana-icon.png | 3 - openpilot/tools/cabana/binaryview.cc | 510 ------------ openpilot/tools/cabana/binaryview.h | 106 --- openpilot/tools/cabana/cabana | 47 +- openpilot/tools/cabana/cabana.cc | 196 ----- openpilot/tools/cabana/cameraview.cc | 121 --- openpilot/tools/cabana/cameraview.h | 49 -- openpilot/tools/cabana/chart/chart.cc | 769 ------------------ openpilot/tools/cabana/chart/chart.h | 132 --- openpilot/tools/cabana/chart/chartswidget.cc | 660 --------------- openpilot/tools/cabana/chart/chartswidget.h | 140 ---- .../tools/cabana/chart/signalselector.cc | 107 --- openpilot/tools/cabana/chart/signalselector.h | 30 - openpilot/tools/cabana/chart/sparkline.cc | 101 --- openpilot/tools/cabana/chart/sparkline.h | 26 - openpilot/tools/cabana/chart/tiplabel.cc | 58 -- openpilot/tools/cabana/chart/tiplabel.h | 12 - openpilot/tools/cabana/detailwidget.cc | 323 -------- openpilot/tools/cabana/detailwidget.h | 78 -- openpilot/tools/cabana/historylog.cc | 251 ------ openpilot/tools/cabana/historylog.h | 83 -- openpilot/tools/cabana/mainwin.cc | 752 ----------------- openpilot/tools/cabana/mainwin.h | 122 --- openpilot/tools/cabana/messageswidget.cc | 467 ----------- openpilot/tools/cabana/messageswidget.h | 131 --- openpilot/tools/cabana/routesdialog.cc | 110 --- openpilot/tools/cabana/routesdialog.h | 31 - openpilot/tools/cabana/settingsdialog.cc | 88 -- openpilot/tools/cabana/settingsdialog.h | 20 - openpilot/tools/cabana/signalview.cc | 719 ---------------- openpilot/tools/cabana/signalview.h | 155 ---- openpilot/tools/cabana/streamselector.cc | 330 -------- openpilot/tools/cabana/streamselector.h | 99 --- openpilot/tools/cabana/tools/findsignal.cc | 286 ------- openpilot/tools/cabana/tools/findsignal.h | 71 -- .../tools/cabana/tools/findsimilarbits.cc | 161 ---- .../tools/cabana/tools/findsimilarbits.h | 36 - openpilot/tools/cabana/tools/routeinfo.cc | 40 - openpilot/tools/cabana/tools/routeinfo.h | 8 - openpilot/tools/cabana/utils/elidedlabel.cc | 29 - openpilot/tools/cabana/utils/elidedlabel.h | 25 - openpilot/tools/cabana/utils/qtutil.cc | 236 ------ openpilot/tools/cabana/utils/qtutil.h | 139 ---- openpilot/tools/cabana/videowidget.cc | 434 ---------- openpilot/tools/cabana/videowidget.h | 82 -- pyproject.toml | 2 +- tools/op.sh | 2 +- uv.lock | 16 +- 53 files changed, 50 insertions(+), 8519 deletions(-) delete mode 100644 openpilot/tools/cabana/assets/assets.qrc delete mode 100644 openpilot/tools/cabana/assets/cabana-icon.png delete mode 100644 openpilot/tools/cabana/binaryview.cc delete mode 100644 openpilot/tools/cabana/binaryview.h delete mode 100644 openpilot/tools/cabana/cabana.cc delete mode 100644 openpilot/tools/cabana/cameraview.cc delete mode 100644 openpilot/tools/cabana/cameraview.h delete mode 100644 openpilot/tools/cabana/chart/chart.cc delete mode 100644 openpilot/tools/cabana/chart/chart.h delete mode 100644 openpilot/tools/cabana/chart/chartswidget.cc delete mode 100644 openpilot/tools/cabana/chart/chartswidget.h delete mode 100644 openpilot/tools/cabana/chart/signalselector.cc delete mode 100644 openpilot/tools/cabana/chart/signalselector.h delete mode 100644 openpilot/tools/cabana/chart/sparkline.cc delete mode 100644 openpilot/tools/cabana/chart/sparkline.h delete mode 100644 openpilot/tools/cabana/chart/tiplabel.cc delete mode 100644 openpilot/tools/cabana/chart/tiplabel.h delete mode 100644 openpilot/tools/cabana/detailwidget.cc delete mode 100644 openpilot/tools/cabana/detailwidget.h delete mode 100644 openpilot/tools/cabana/historylog.cc delete mode 100644 openpilot/tools/cabana/historylog.h delete mode 100644 openpilot/tools/cabana/mainwin.cc delete mode 100644 openpilot/tools/cabana/mainwin.h delete mode 100644 openpilot/tools/cabana/messageswidget.cc delete mode 100644 openpilot/tools/cabana/messageswidget.h delete mode 100644 openpilot/tools/cabana/routesdialog.cc delete mode 100644 openpilot/tools/cabana/routesdialog.h delete mode 100644 openpilot/tools/cabana/settingsdialog.cc delete mode 100644 openpilot/tools/cabana/settingsdialog.h delete mode 100644 openpilot/tools/cabana/signalview.cc delete mode 100644 openpilot/tools/cabana/signalview.h delete mode 100644 openpilot/tools/cabana/streamselector.cc delete mode 100644 openpilot/tools/cabana/streamselector.h delete mode 100644 openpilot/tools/cabana/tools/findsignal.cc delete mode 100644 openpilot/tools/cabana/tools/findsignal.h delete mode 100644 openpilot/tools/cabana/tools/findsimilarbits.cc delete mode 100644 openpilot/tools/cabana/tools/findsimilarbits.h delete mode 100644 openpilot/tools/cabana/tools/routeinfo.cc delete mode 100644 openpilot/tools/cabana/tools/routeinfo.h delete mode 100644 openpilot/tools/cabana/utils/elidedlabel.cc delete mode 100644 openpilot/tools/cabana/utils/elidedlabel.h delete mode 100644 openpilot/tools/cabana/utils/qtutil.cc delete mode 100644 openpilot/tools/cabana/utils/qtutil.h delete mode 100644 openpilot/tools/cabana/videowidget.cc delete mode 100644 openpilot/tools/cabana/videowidget.h diff --git a/SConstruct b/SConstruct index bb5c35feb7..c6d758b318 100644 --- a/SConstruct +++ b/SConstruct @@ -87,7 +87,6 @@ acados_include_dirs = [ # vendored in commaai/dependencies. allowed_system_libs = { "EGL", "GLESv2", "GL", - "Qt5Charts", "Qt5Core", "Qt5Gui", "Qt5Widgets", "dl", "drm", "gbm", "m", "pthread", } diff --git a/openpilot/tools/cabana/.gitignore b/openpilot/tools/cabana/.gitignore index c5c752ba9c..788b286372 100644 --- a/openpilot/tools/cabana/.gitignore +++ b/openpilot/tools/cabana/.gitignore @@ -1,12 +1,5 @@ -moc_* -*.moc -*.generated.qrc - -assets.cc bootstrap_icons.cc -_cabana _cabana_ui dbc/car_fingerprint_to_dbc.json tests/test_cabana -tests/test_dbc_core diff --git a/openpilot/tools/cabana/README.md b/openpilot/tools/cabana/README.md index fbdfbb40e5..6fcce59eb0 100644 --- a/openpilot/tools/cabana/README.md +++ b/openpilot/tools/cabana/README.md @@ -6,29 +6,27 @@ Cabana is a tool developed to view raw CAN data. One use for this is creating an ```bash $ ./cabana -h -Usage: ./cabana [options] route +Usage: ./cabana [options] [route] + + route the drive to replay. find your drives at connect.comma.ai Options: - -h, --help Displays help on commandline options. - --help-all Displays help including Qt specific options. - --demo use a demo route instead of providing your own - --auto Auto load the route from the best available source (no video): - internal, openpilotci, comma_api, car_segments, testing_closet - --qcam load qcamera - --wide-road load wide road camera - --msgq read can messages from msgq - --panda read can messages from panda - --panda-serial read can messages from panda with given serial - --socketcan read can messages from given SocketCAN device - --zmq read can messages from zmq at the specified ip-address - messages - --data_dir local directory with routes - --no-vipc do not output video - --dbc dbc file to open - -Arguments: - route the drive to replay. find your drives at - connect.comma.ai + --help show this help + --demo use a demo route instead of providing your own + --auto Auto load the route from the best available source (no video): + internal, openpilotci, comma_api, car_segments, testing_closet + --qcam load qcamera + --wide-road load wide road camera (alias: --ecam) + --cabin load cabin camera (alias: --dcam) + --msgq read can messages from the msgq + --panda read can messages from panda + --panda-serial read can messages from panda with given serial + --socketcan read can messages from given SocketCAN device + --zmq read can messages from zmq at the specified ip-address + --data_dir local directory with routes + --no-vipc do not output video + --no-cache turn off the local route file cache + --dbc dbc file to open ``` ## Examples diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index a324e09d67..81fd8877c4 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -1,6 +1,4 @@ -import subprocess import os -import shutil import bootstrap_icons import imgui @@ -27,8 +25,7 @@ def build_bootstrap_icons_src(target, source, env): bootstrap_icons_src = env.Command('assets/bootstrap_icons.cc', str(bootstrap_icons.SVG_PATH), build_bootstrap_icons_src) -# Qt-free sources shared by the imgui frontend and the core test. Compiled in the base env, -# so the object names must not collide with the Qt build of the same sources. +# sources shared by the imgui frontend and the core test core_srcs = ['streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'utils/export.cc', 'utils/util.cc', 'utils/strings.cc', 'commands.cc', 'settings.cc', 'routes.cc', 'panda.cc'] @@ -56,109 +53,25 @@ else: ui_libs += ['GL', 'dl'] cabana_ui = ui_env.Program('_cabana_ui', ui_objs, LIBS=ui_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"] - -qt_libs = [] -if arch == "Darwin": - 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] - 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() - qt_install_headers = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_HEADERS'], encoding='utf8').strip() - - qt_env['QTDIR'] = qt_install_prefix - qt_dirs = [ - f"{qt_install_headers}", - ] - - qt_gui_path = os.path.join(qt_install_headers, "QtGui") - qt_gui_dirs = [d for d in os.listdir(qt_gui_path) if os.path.isdir(os.path.join(qt_gui_path, d))] - qt_dirs += [f"{qt_install_headers}/QtGui/{qt_gui_dirs[0]}/QtGui", ] if qt_gui_dirs else [] - qt_dirs += [f"{qt_install_headers}/Qt{m}" for m in qt_modules] - - qt_libs = [f"Qt5{m}" for m in qt_modules] -qt_env['QT3DIR'] = qt_env['QTDIR'] -qt_env.Tool('qt3') - -qt_env['CPPPATH'] += qt_dirs -qt_flags = [ - "-D_REENTRANT", - "-DQT_NO_DEBUG", - "-DQT_WIDGETS_LIB", - "-DQT_GUI_LIB", - "-DQT_CORE_LIB", - "-DQT_MESSAGELOGCONTEXT", -] -qt_env['CXXFLAGS'] += qt_flags -qt_env['LIBPATH'] += ['#openpilot/selfdrive/ui', ] -qt_env['LIBS'] = qt_libs - -base_frameworks = qt_env['FRAMEWORKS'] -base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] + qt_env["LIBS"] - -if arch == "Darwin": - 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] + ffmpeg_libs + ['usb-1.0'] + base_libs -cabana_env['CXXFLAGS'] += [opendbc_path] - -# build assets -assets = "assets/assets.cc" -cabana_env.Command(assets, "assets/assets.qrc", f"rcc $SOURCES -o $TARGET") -cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"])) - -cabana_srcs = ['mainwin.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', 'routesdialog.cc', - 'utils/qtutil.cc', 'utils/elidedlabel.cc', - 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', - 'messageswidget.cc', 'streamselector.cc', 'settingsdialog.cc', - 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] + core_srcs -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'): - # 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.Object('tests/dbc_core_strings', 'utils/strings.cc'), - dbc_core_test_env.Object('tests/dbc_core_util', 'utils/util.cc'), - dbc_core_test_env.Object('tests/dbc_core_icons', bootstrap_icons_src), - dbc_core_test_env.Object('tests/dbc_core_routes', 'routes.cc'), - dbc_core_test_env.Object('tests/dbc_core_qtstate', 'ui/qtstate.cc'), + cabana_core_test_env = env.Clone() + cabana_core_test_env['CXXFLAGS'] += [opendbc_path] + cabana_core_test_objects = [ + cabana_core_test_env.Object('tests/cabana_core_tests', 'tests/test_cabana.cc'), + cabana_core_test_env.Object('tests/cabana_core_model', 'dbc/dbc.cc'), + cabana_core_test_env.Object('tests/cabana_core_file', 'dbc/dbcfile.cc'), + cabana_core_test_env.Object('tests/cabana_core_manager', 'dbc/dbcmanager.cc'), + cabana_core_test_env.Object('tests/cabana_core_strings', 'utils/strings.cc'), + cabana_core_test_env.Object('tests/cabana_core_util', 'utils/util.cc'), + cabana_core_test_env.Object('tests/cabana_core_icons', bootstrap_icons_src), + cabana_core_test_env.Object('tests/cabana_core_routes', 'routes.cc'), + cabana_core_test_env.Object('tests/cabana_core_qtstate', 'ui/qtstate.cc'), ] - dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects, LIBS=[replay_lib, common]) + cabana_core_test_env.Program('tests/test_cabana', cabana_core_test_objects, LIBS=[replay_lib, common]) output_json_file = 'openpilot/tools/cabana/dbc/car_fingerprint_to_dbc.json' -generate_dbc = cabana_env.Command('#' + output_json_file, - ['dbc/generate_dbc_json.py'], - "python3 openpilot/tools/cabana/dbc/generate_dbc_json.py --out " + output_json_file) -cabana_env.Depends(generate_dbc, ["#openpilot/common", '#opendbc_repo', "#openpilot/cereal", "#msgq_repo"]) +generate_dbc = ui_env.Command('#' + output_json_file, + ['dbc/generate_dbc_json.py'], + "python3 openpilot/tools/cabana/dbc/generate_dbc_json.py --out " + output_json_file) +ui_env.Depends(generate_dbc, ["#openpilot/common", '#opendbc_repo', "#openpilot/cereal", "#msgq_repo"]) ui_env.Depends(cabana_ui, generate_dbc) diff --git a/openpilot/tools/cabana/assets/assets.qrc b/openpilot/tools/cabana/assets/assets.qrc deleted file mode 100644 index 009d63f008..0000000000 --- a/openpilot/tools/cabana/assets/assets.qrc +++ /dev/null @@ -1,5 +0,0 @@ - - - cabana-icon.png - - diff --git a/openpilot/tools/cabana/assets/cabana-icon.png b/openpilot/tools/cabana/assets/cabana-icon.png deleted file mode 100644 index 2c3cfcd7d1..0000000000 --- a/openpilot/tools/cabana/assets/cabana-icon.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:015cea9d425f747bce856fd2f5a6a57fca322159dde54554de11eda076273595 -size 18008 diff --git a/openpilot/tools/cabana/binaryview.cc b/openpilot/tools/cabana/binaryview.cc deleted file mode 100644 index 20fd2ec1ec..0000000000 --- a/openpilot/tools/cabana/binaryview.cc +++ /dev/null @@ -1,510 +0,0 @@ -#include "tools/cabana/binaryview.h" - -#include - -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/utils/qtutil.h" - -// BinaryView - -const int CELL_HEIGHT = 36; -const int VERTICAL_HEADER_WIDTH = 30; -inline int get_bit_pos(const QModelIndex &index) { return flipBitPos(index.row() * 8 + index.column()); } - -BinaryView::BinaryView(QWidget *parent) : QTableView(parent) { - model = new BinaryViewModel(this); - setModel(model); - delegate = new BinaryItemDelegate(this); - setItemDelegate(delegate); - horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); - horizontalHeader()->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); - verticalHeader()->setSectionsClickable(false); - verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); - verticalHeader()->setDefaultSectionSize(CELL_HEIGHT); - horizontalHeader()->hide(); - setShowGrid(false); - setMouseTracking(true); - setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); - connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); - - addShortcuts(); - setWhatsThis(R"( - Binary View
- - Shortcuts
- Delete Signal: -  x , -  Backspace , -  Delete 
- Change endianness:  e 
- Change signedness:  s 
- Open chart: -  c , -  p , -  g  - )"); -} - -void BinaryView::addShortcuts() { - // Delete (x, backspace, delete) - QShortcut *shortcut_delete_x = new QShortcut(QKeySequence(Qt::Key_X), this); - QShortcut *shortcut_delete_backspace = new QShortcut(QKeySequence(Qt::Key_Backspace), this); - QShortcut *shortcut_delete_delete = new QShortcut(QKeySequence(Qt::Key_Delete), this); - QObject::connect(shortcut_delete_delete, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated); - QObject::connect(shortcut_delete_backspace, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated); - QObject::connect(shortcut_delete_x, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - UndoStack::instance()->push(new RemoveSigCommand(model->msg_id, hovered_sig)); - hovered_sig = nullptr; - } - }); - - // Change endianness (e) - QShortcut *shortcut_endian = new QShortcut(QKeySequence(Qt::Key_E), this); - QObject::connect(shortcut_endian, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - cabana::Signal s = *hovered_sig; - s.is_little_endian = !s.is_little_endian; - emit editSignal(hovered_sig, s); - } - }); - - // Change signedness (s) - QShortcut *shortcut_sign = new QShortcut(QKeySequence(Qt::Key_S), this); - QObject::connect(shortcut_sign, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - cabana::Signal s = *hovered_sig; - s.is_signed = !s.is_signed; - emit editSignal(hovered_sig, s); - } - }); - - // Open chart (c, p, g) - QShortcut *shortcut_plot = new QShortcut(QKeySequence(Qt::Key_P), this); - QShortcut *shortcut_plot_g = new QShortcut(QKeySequence(Qt::Key_G), this); - QShortcut *shortcut_plot_c = new QShortcut(QKeySequence(Qt::Key_C), this); - QObject::connect(shortcut_plot_g, &QShortcut::activated, shortcut_plot, &QShortcut::activated); - QObject::connect(shortcut_plot_c, &QShortcut::activated, shortcut_plot, &QShortcut::activated); - QObject::connect(shortcut_plot, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - emit showChart(model->msg_id, hovered_sig, true, false); - } - }); -} - -QSize BinaryView::minimumSizeHint() const { - return {(horizontalHeader()->minimumSectionSize() + 1) * 9 + VERTICAL_HEADER_WIDTH + 2, - CELL_HEIGHT * std::min(model->rowCount(), 10) + 2}; -} - -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; - 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}); - } - } - - hovered_sig = sig; - emit signalHovered(hovered_sig); - } -} - -void BinaryView::setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) { - auto index = indexAt(last_mouse_pos); - if (!anchor_index.isValid() || !index.isValid()) - return; - - QItemSelection selection; - auto [start, size, is_lb] = getSelection(index); - for (int i = 0; i < size; ++i) { - int pos = is_lb ? flipBitPos(start + i) : flipBitPos(start) + i; - selection << QItemSelectionRange{model->index(pos / 8, pos % 8)}; - } - selectionModel()->select(selection, flags); -} - -void BinaryView::mousePressEvent(QMouseEvent *event) { - resize_sig = nullptr; - 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); - for (auto s : item->sigs) { - if (bit_pos == s->lsb || bit_pos == s->msb) { - int idx = flipBitPos(bit_pos == s->lsb ? s->msb : s->lsb); - anchor_index = model->index(idx / 8, idx % 8); - resize_sig = s; - break; - } - } - } - event->accept(); -} - -void BinaryView::highlightPosition(const QPoint &pos) { - if (auto index = indexAt(pos); index.isValid()) { - auto item = (BinaryViewModel::Item *)index.internalPointer(); - const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back(); - highlight(sig); - } -} - -void BinaryView::mouseMoveEvent(QMouseEvent *event) { - highlightPosition(last_mouse_pos = event->pos()); - QTableView::mouseMoveEvent(event); -} - -void BinaryView::mouseReleaseEvent(QMouseEvent *event) { - QTableView::mouseReleaseEvent(event); - - auto release_index = indexAt(event->pos()); - if (release_index.isValid() && anchor_index.isValid()) { - if (selectionModel()->hasSelection()) { - 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::instance()->push(new AddSigCommand(model->msg_id, sig)); - } else { - auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer(); - if (item && item->sigs.size() > 0) - emit signalClicked(item->sigs.back()); - } - } - clearSelection(); - anchor_index = QModelIndex(); - resize_sig = nullptr; -} - -void BinaryView::leaveEvent(QEvent *event) { - highlight(nullptr); - QTableView::leaveEvent(event); -} - -void BinaryView::setMessage(const MessageId &message_id) { - model->msg_id = message_id; - verticalScrollBar()->setValue(0); - refresh(); -} - -void BinaryView::refresh() { - clearSelection(); - anchor_index = QModelIndex(); - resize_sig = nullptr; - hovered_sig = nullptr; - model->refresh(); - if (underMouse()) highlightPosition(last_mouse_pos); -} - -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.insert(s); - } - } - } - return overlapping; -} - -std::tuple BinaryView::getSelection(QModelIndex index) { - if (index.column() == 8) { - index = model->index(index.row(), 7); - } - bool is_lb = true; - if (resize_sig) { - is_lb = resize_sig->is_little_endian; - } else if (settings.drag_direction == Settings::DragDirection::MsbFirst) { - is_lb = index < anchor_index; - } else if (settings.drag_direction == Settings::DragDirection::LsbFirst) { - is_lb = !(index < anchor_index); - } else if (settings.drag_direction == Settings::DragDirection::AlwaysLE) { - is_lb = true; - } else if (settings.drag_direction == Settings::DragDirection::AlwaysBE) { - is_lb = false; - } - - int cur_bit_pos = get_bit_pos(index); - int anchor_bit_pos = get_bit_pos(anchor_index); - int start_bit = is_lb ? std::min(cur_bit_pos, anchor_bit_pos) : get_bit_pos(std::min(index, anchor_index)); - int size = is_lb ? std::abs(cur_bit_pos - anchor_bit_pos) + 1 : std::abs(flipBitPos(cur_bit_pos) - flipBitPos(anchor_bit_pos)) + 1; - return {start_bit, size, is_lb}; -} - -// BinaryViewModel - -void BinaryViewModel::refresh() { - beginResetModel(); - bit_flip_tracker = {}; - items.clear(); - if (auto dbc_msg = dbc()->msg(msg_id)) { - row_count = dbc_msg->size; - items.resize(row_count * column_count); - for (auto sig : dbc_msg->getSignals()) { - for (int j = 0; j < sig->size; ++j) { - 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()) { - 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; - if (j == sig->size - 1) sig->is_little_endian ? items[idx].is_msb = true : items[idx].is_lsb = true; - - auto &sigs = items[idx].sigs; - sigs.push_back(sig); - if (sigs.size() > 1) { - std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) { return l->size > r->size; }); - } - } - } - } else { - row_count = can->lastMessage(msg_id).dat.size(); - items.resize(row_count * column_count); - } - endResetModel(); - updateState(); -} - -void BinaryViewModel::updateItem(int row, int col, uint8_t val, const QColor &color) { - auto &item = items[row * column_count + col]; - item.valid = true; - if (item.val != val || item.bg_color != color) { - item.val = val; - item.bg_color = color; - auto idx = index(row, col); - emit dataChanged(idx, idx, {Qt::DisplayRole}); - } -} - -void BinaryViewModel::updateState() { - const auto &last_msg = can->lastMessage(msg_id); - const auto &binary = last_msg.dat; - // Handle size changes in binary data - if (binary.size() > row_count) { - beginInsertRows({}, row_count, binary.size() - 1); - row_count = binary.size(); - items.resize(row_count * column_count); - endInsertRows(); - } - - auto &bit_flips = heatmap_live_mode ? last_msg.bit_flip_counts : getBitFlipChanges(binary.size()); - // Find the maximum bit flip count across the message - uint32_t max_bit_flip_count = 1; // Default to 1 to avoid division by zero - for (const auto &row : bit_flips) { - for (uint32_t count : row) { - max_bit_flip_count = std::max(max_bit_flip_count, count); - } - } - - const double max_alpha = 255.0; - const double min_alpha_with_signal = 25.0; // Base alpha for small flip counts - const double min_alpha_no_signal = 10.0; // Base alpha for small flip counts for no signal bits - const double log_factor = 1.0 + 0.2; // Factor for logarithmic scaling - const double log_scaler = max_alpha / log2(log_factor * max_bit_flip_count); - - for (size_t i = 0; i < binary.size(); ++i) { - for (int j = 0; j < 8; ++j) { - auto &item = items[i * column_count + j]; - int bit_val = (binary[i] >> (7 - j)) & 1; - - double alpha = item.sigs.empty() ? 0 : min_alpha_with_signal; - uint32_t flip_count = bit_flips[i][j]; - if (flip_count > 0) { - double normalized_alpha = log2(1.0 + flip_count * log_factor) * log_scaler; - double min_alpha = item.sigs.empty() ? min_alpha_no_signal : min_alpha_with_signal; - alpha = std::clamp(normalized_alpha, min_alpha, max_alpha); - } - - auto color = item.bg_color; - color.setAlpha(alpha); - updateItem(i, j, bit_val, color); - } - updateItem(i, 8, binary[i], toQColor(last_msg.colors[i])); - } -} - -const std::vector> &BinaryViewModel::getBitFlipChanges(size_t msg_size) { - // Return cached results if time range and data are unchanged - auto time_range = can->timeRange(); - if (bit_flip_tracker.time_range == time_range && !bit_flip_tracker.flip_counts.empty()) - return bit_flip_tracker.flip_counts; - - bit_flip_tracker.time_range = time_range; - bit_flip_tracker.flip_counts.assign(msg_size, std::array{}); - - // Iterate over events within the specified time range and calculate bit flips - auto [first, last] = can->eventsInRange(msg_id, time_range); - if (std::distance(first, last) <= 1) return bit_flip_tracker.flip_counts; - - std::vector prev_values((*first)->dat, (*first)->dat + (*first)->size); - for (auto it = std::next(first); it != last; ++it) { - const CanEvent *event = *it; - int size = std::min(msg_size, event->size); - for (int i = 0; i < size; ++i) { - const uint8_t diff = event->dat[i] ^ prev_values[i]; - if (!diff) continue; - - auto &bit_flips = bit_flip_tracker.flip_counts[i]; - for (int bit = 0; bit < 8; ++bit) { - if (diff & (1u << bit)) ++bit_flips[7 - bit]; - } - prev_values[i] = event->dat[i]; - } - } - - return bit_flip_tracker.flip_counts; -} - -QVariant BinaryViewModel::headerData(int section, Qt::Orientation orientation, int role) const { - if (orientation == Qt::Vertical) { - switch (role) { - case Qt::DisplayRole: return section; - case Qt::SizeHintRole: return QSize(VERTICAL_HEADER_WIDTH, 0); - case Qt::TextAlignmentRole: return Qt::AlignCenter; - } - } - return {}; -} - -QVariant BinaryViewModel::data(const QModelIndex &index, int role) const { - auto item = (const BinaryViewModel::Item *)index.internalPointer(); - return role == Qt::ToolTipRole && item && !item->sigs.empty() ? QString::fromStdString(utils::signalToolTip(item->sigs.back())) : QVariant(); -} - -// BinaryItemDelegate - -BinaryItemDelegate::BinaryItemDelegate(QObject *parent) : QStyledItemDelegate(parent) { - small_font.setPixelSize(8); - hex_font = QFontDatabase::systemFont(QFontDatabase::FixedFont); - hex_font.setBold(true); - - bin_text_table[0].setText("0"); - bin_text_table[1].setText("1"); - for (int i = 0; i < 256; ++i) { - hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper()); - hex_text_table[i].prepare({}, hex_font); - } -} - -bool BinaryItemDelegate::hasSignal(const QModelIndex &index, int dx, int dy, const cabana::Signal *sig) const { - if (!index.isValid()) return false; - auto model = (const BinaryViewModel*)(index.model()); - int idx = (index.row() + dy) * model->columnCount() + index.column() + dx; - 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 { - auto item = (const BinaryViewModel::Item *)index.internalPointer(); - BinaryView *bin_view = (BinaryView *)parent(); - painter->save(); - - if (index.column() == 8) { - if (item->valid) { - painter->setFont(hex_font); - painter->fillRect(option.rect, item->bg_color); - } - } else if (option.state & QStyle::State_Selected) { - 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() || 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, toQColor(s->color.darker(125))); // 4/5x brightness - } else { - drawSignalCell(painter, option, index, s); - } - } - } else if (item->valid && item->bg_color.alpha() > 0) { - painter->fillRect(option.rect, item->bg_color); - } - 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)); - } - - if (item->sigs.size() > 1) { - painter->fillRect(option.rect, QBrush(Qt::darkGray, Qt::Dense7Pattern)); - } else if (!item->valid) { - painter->fillRect(option.rect, QBrush(Qt::darkGray, Qt::BDiagPattern)); - } - if (item->valid) { - utils::drawStaticText(painter, option.rect, index.column() == 8 ? hex_text_table[item->val] : bin_text_table[item->val]); - } - if (item->is_msb || item->is_lsb) { - painter->setFont(small_font); - painter->drawText(option.rect.adjusted(8, 0, -8, -3), Qt::AlignRight | Qt::AlignBottom, item->is_msb ? "M" : "L"); - } - painter->restore(); -} - -// Draw border on edge of signal -void BinaryItemDelegate::drawSignalCell(QPainter *painter, const QStyleOptionViewItem &option, - const QModelIndex &index, const cabana::Signal *sig) const { - bool draw_left = !hasSignal(index, -1, 0, sig); - bool draw_top = !hasSignal(index, 0, -1, sig); - bool draw_right = !hasSignal(index, 1, 0, sig); - bool draw_bottom = !hasSignal(index, 0, 1, sig); - - const int spacing = 2; - QRect rc = option.rect.adjusted(draw_left * 3, draw_top * spacing, draw_right * -3, draw_bottom * -spacing); - QRegion subtract; - if (!draw_top) { - if (!draw_left && !hasSignal(index, -1, -1, sig)) { - subtract += QRect{rc.left(), rc.top(), 3, spacing}; - } else if (!draw_right && !hasSignal(index, 1, -1, sig)) { - subtract += QRect{rc.right() - 2, rc.top(), 3, spacing}; - } - } - if (!draw_bottom) { - if (!draw_left && !hasSignal(index, -1, 1, sig)) { - subtract += QRect{rc.left(), rc.bottom() - (spacing - 1), 3, spacing}; - } else if (!draw_right && !hasSignal(index, 1, 1, sig)) { - subtract += QRect{rc.right() - 2, rc.bottom() - (spacing - 1), 3, spacing}; - } - } - painter->setClipRegion(QRegion(rc).subtracted(subtract)); - - auto item = (const BinaryViewModel::Item *)index.internalPointer(); - 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 = 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()); - if (draw_bottom) painter->drawLine(rc.bottomLeft(), rc.bottomRight()); - if (draw_top) painter->drawLine(rc.topLeft(), rc.topRight()); - - if (!subtract.isEmpty()) { - // fill gaps inside corners. - painter->setPen(QPen(color, 2, Qt::SolidLine, Qt::SquareCap, Qt::MiterJoin)); - for (auto &r : subtract) { - painter->drawRect(r); - } - } -} diff --git a/openpilot/tools/cabana/binaryview.h b/openpilot/tools/cabana/binaryview.h deleted file mode 100644 index c918ee01e7..0000000000 --- a/openpilot/tools/cabana/binaryview.h +++ /dev/null @@ -1,106 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" - -class BinaryItemDelegate : public QStyledItemDelegate { -public: - BinaryItemDelegate(QObject *parent); - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - bool hasSignal(const QModelIndex &index, int dx, int dy, const cabana::Signal *sig) const; - void drawSignalCell(QPainter* painter, const QStyleOptionViewItem &option, const QModelIndex &index, const cabana::Signal *sig) const; - - QFont small_font, hex_font; - std::array hex_text_table; - std::array bin_text_table; -}; - -class BinaryViewModel : public QAbstractTableModel { -public: - BinaryViewModel(QObject *parent) : QAbstractTableModel(parent) {} - void refresh(); - void updateState(); - void updateItem(int row, int col, uint8_t val, const QColor &color); - 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 rowCount(const QModelIndex &parent = QModelIndex()) const override { return row_count; } - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return column_count; } - QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override { - return createIndex(row, column, (void *)&items[row * column_count + column]); - } - Qt::ItemFlags flags(const QModelIndex &index) const override { - return (index.column() == column_count - 1) ? Qt::ItemIsEnabled : Qt::ItemIsEnabled | Qt::ItemIsSelectable; - } - const std::vector> &getBitFlipChanges(size_t msg_size); - - struct BitFlipTracker { - std::optional> time_range; - std::vector> flip_counts; - } bit_flip_tracker; - - struct Item { - QColor bg_color = QColor(102, 86, 169, 255); - bool is_msb = false; - bool is_lsb = false; - uint8_t val; - std::vector sigs; - bool valid = false; - }; - std::vector items; - bool heatmap_live_mode = true; - MessageId msg_id; - int row_count = 0; - const int column_count = 9; -}; - -class BinaryView : public QTableView { - Q_OBJECT - -public: - BinaryView(QWidget *parent = nullptr); - void setMessage(const MessageId &message_id); - void highlight(const cabana::Signal *sig); - std::set getOverlappingSignals() const; - void updateState() { model->updateState(); } - void paintEvent(QPaintEvent *event) override { - is_message_active = can->isMessageActive(model->msg_id); - QTableView::paintEvent(event); - } - QSize minimumSizeHint() const override; - void setHeatmapLiveMode(bool live) { model->heatmap_live_mode = live; updateState(); } - -signals: - void signalClicked(const cabana::Signal *sig); - void signalHovered(const cabana::Signal *sig); - void editSignal(const cabana::Signal *origin_s, cabana::Signal &s); - void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); - -private: - void addShortcuts(); - void refresh(); - std::tuple getSelection(QModelIndex index); - void setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) override; - void mousePressEvent(QMouseEvent *event) override; - void mouseMoveEvent(QMouseEvent *event) override; - void mouseReleaseEvent(QMouseEvent *event) override; - void leaveEvent(QEvent *event) override; - void highlightPosition(const QPoint &pt); - - QModelIndex anchor_index; - QPoint last_mouse_pos{-1, -1}; - BinaryViewModel *model; - BinaryItemDelegate *delegate; - bool is_message_active = false; - const cabana::Signal *resize_sig = nullptr; - const cabana::Signal *hovered_sig = nullptr; - Connections connections_; - friend class BinaryItemDelegate; -}; diff --git a/openpilot/tools/cabana/cabana b/openpilot/tools/cabana/cabana index fb995a05c2..bdec614366 100755 --- a/openpilot/tools/cabana/cabana +++ b/openpilot/tools/cabana/cabana @@ -4,50 +4,7 @@ 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 -} - -# --legacy runs the old Qt cabana until it is deprecated -LEGACY=0 -ARGS=() -for arg in "$@"; do - if [[ "$arg" == "--legacy" ]]; then - LEGACY=1 - else - ARGS+=("$arg") - fi -done - -if [[ $LEGACY -eq 1 ]]; then - # Install Qt if not found - if ! command -v qmake &> /dev/null; then - echo "Qt not found, installing dependencies..." - install_qt - fi - TARGET="_cabana" -else - TARGET="_cabana_ui" -fi - cd "$ROOT" -scons -u "openpilot/tools/cabana/$TARGET" openpilot/cereal/messaging/bridge +scons -u "openpilot/tools/cabana/_cabana_ui" openpilot/cereal/messaging/bridge -exec "$DIR/$TARGET" "${ARGS[@]}" +exec "$DIR/_cabana_ui" "$@" diff --git a/openpilot/tools/cabana/cabana.cc b/openpilot/tools/cabana/cabana.cc deleted file mode 100644 index b18484c556..0000000000 --- a/openpilot/tools/cabana/cabana.cc +++ /dev/null @@ -1,196 +0,0 @@ -#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 -#include "tools/cabana/utils/qtutil.h" - -namespace { - -struct CabanaArgs { - bool demo = false; - bool auto_source = false; - bool qcam = false; - bool wide_road = false; - bool cabin = false; - bool msgq = false; - bool panda = false; - bool no_vipc = false; - std::string panda_serial; - std::string socketcan; - std::string zmq; - 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 connect.comma.ai\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" - " --wide-road load wide road camera (alias: --ecam)\n" - " --cabin load cabin camera (alias: --dcam)\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 read can messages from zmq at the specified ip-address\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, "--wide-road") == 0 || std::strcmp(a, "--ecam") == 0) { - args.wide_road = true; - } else if (std::strcmp(a, "--cabin") == 0 || std::strcmp(a, "--dcam") == 0) { - args.cabin = 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, "--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"); - initApp(argc, argv, false); - QApplication app(argc, argv); - app.setApplicationDisplayName("Cabana"); - //app.setWindowIcon(QIcon(":cabana-icon.png")); // TODO: do this in imgui - - // Marshal exit onto the GUI thread (qApp methods are not thread-safe). - UnixSignalHandler signalHandler([]() { - QMetaObject::invokeMethod(qApp, []() { - printf("\nexiting...\n"); - qApp->closeAllWindows(); - qApp->exit(); - }, Qt::QueuedConnection); - }); - utils::setTheme(settings.theme); - - CabanaArgs args; - bool args_ok = false; - if (const int code = parseArgs(argc, argv, args, args_ok); !args_ok) { - return code; - } - - AbstractStream *stream = nullptr; - - if (args.msgq) { - stream = new DeviceStream(); - } else if (!args.zmq.empty()) { - stream = new DeviceStream(args.zmq); - } else if (args.panda || !args.panda_serial.empty()) { - try { - stream = new PandaStream({.serial = args.panda_serial}); - } catch (std::exception &e) { - fprintf(stderr, "%s\n", e.what()); - return 0; - } -#ifdef __linux__ - } else if (SocketCanStream::available() && !args.socketcan.empty()) { - stream = new SocketCanStream({.device = args.socketcan}); -#endif - } else { - uint32_t replay_flags = REPLAY_FLAG_NONE; - if (args.wide_road) replay_flags |= REPLAY_FLAG_WIDE_ROAD; - if (args.qcam) replay_flags |= REPLAY_FLAG_QCAMERA; - if (args.cabin) replay_flags |= REPLAY_FLAG_CABIN_CAMERA; - if (args.no_vipc) replay_flags |= REPLAY_FLAG_NO_VIPC; - - QString route; - 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(); - Connection err = replay_stream->error.connect([](const std::string &msg) { fprintf(stderr, "%s\n", msg.c_str()); }); - if (!replay_stream->loadRoute(route.toStdString(), args.data_dir, replay_flags, args.auto_source)) { - return 0; - } - stream = replay_stream.release(); - } - } - - MainWindow w(stream, QString::fromStdString(args.dbc)); - return app.exec(); -} diff --git a/openpilot/tools/cabana/cameraview.cc b/openpilot/tools/cabana/cameraview.cc deleted file mode 100644 index b8c70ab1bf..0000000000 --- a/openpilot/tools/cabana/cameraview.cc +++ /dev/null @@ -1,121 +0,0 @@ -#include "tools/cabana/cameraview.h" - -#include -#include -#include -#include - -#include -#include - -#include "common/yuv.h" -#include "tools/cabana/utils/util.h" - -CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type, QWidget* parent) : - stream_name(stream_name), active_stream_type(type), requested_stream_type(type), QWidget(parent) { - setAttribute(Qt::WA_OpaquePaintEvent); - QObject::connect(QApplication::instance(), &QCoreApplication::aboutToQuit, this, &CameraWidget::stopVipcThread); -} - -CameraWidget::~CameraWidget() { - stopVipcThread(); -} - -void CameraWidget::showEvent(QShowEvent *event) { - if (!vipc_thread.joinable()) { - clearFrames(); - vipc_exit = false; - vipc_thread = std::thread(&CameraWidget::vipcThread, this); - } -} - -void CameraWidget::stopVipcThread() { - vipc_exit = true; - if (vipc_thread.joinable()) { - vipc_thread.join(); - } -} - -void CameraWidget::paintEvent(QPaintEvent *event) { - QPainter p(this); - p.fillRect(rect(), bg); - - std::lock_guard lk(frame_lock); - if (rgb_frame.isNull()) return; - - // Scale for aspect ratio - float widget_ratio = (float)width() / height(); - 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); - - p.setRenderHint(QPainter::SmoothPixmapTransform); - if (active_stream_type == VISION_STREAM_CABIN) { - // mirror cabin 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::vipcThread() { - VisionStreamType cur_stream = requested_stream_type; - std::unique_ptr vipc_client; - VisionIpcBufExtra frame_meta = {}; - - while (!vipc_exit) { - if (!vipc_client || cur_stream != requested_stream_type) { - clearFrames(); - 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)); - } - active_stream_type = cur_stream; - - if (!vipc_client->connected) { - clearFrames(); - auto streams = VisionIpcClient::getAvailableStreams(stream_name, false); - if (streams.empty()) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - continue; - } - utils::runOnMainThread([this, alive = std::weak_ptr(alive_), streams]() { - if (alive.expired()) return; - available_streams = streams; - availableStreamsUpdated(streams); - }); - - if (!vipc_client->connect(false)) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - continue; - } - } - - 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); - rgb_frame.swap(rgb_back); - } - utils::runOnMainThread([this, alive = std::weak_ptr(alive_)]() { - if (!alive.expired()) update(); - }); - } - } -} - -void CameraWidget::clearFrames() { - std::lock_guard lk(frame_lock); - rgb_frame = QImage(); - rgb_back = QImage(); - available_streams.clear(); -} diff --git a/openpilot/tools/cabana/cameraview.h b/openpilot/tools/cabana/cameraview.h deleted file mode 100644 index ac292a701c..0000000000 --- a/openpilot/tools/cabana/cameraview.h +++ /dev/null @@ -1,49 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include "openpilot/cereal/visionstream.h" -#include "tools/cabana/core/observable.h" -#include "msgq/visionipc/visionipc_client.h" - -class CameraWidget : public QWidget { -public: - explicit CameraWidget(std::string stream_name, VisionStreamType stream_type, QWidget* parent = nullptr); - ~CameraWidget(); - void setStreamType(VisionStreamType type) { requested_stream_type = type; } - VisionStreamType getStreamType() { return active_stream_type; } - void stopVipcThread(); - - Observable<> clicked; - Observable> availableStreamsUpdated; // invoked on the main thread - -protected: - void paintEvent(QPaintEvent *event) override; - void showEvent(QShowEvent *event) override; - void hideEvent(QHideEvent *event) override { stopVipcThread(); } - void mouseReleaseEvent(QMouseEvent *event) override { clicked(); } - void vipcThread(); - void clearFrames(); - - 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; - std::atomic active_stream_type; - std::atomic requested_stream_type; - std::set available_streams; - std::thread vipc_thread; - std::atomic vipc_exit = false; - std::mutex frame_lock; - std::shared_ptr alive_ = std::make_shared(true); -}; diff --git a/openpilot/tools/cabana/chart/chart.cc b/openpilot/tools/cabana/chart/chart.cc deleted file mode 100644 index 478ff3ce9d..0000000000 --- a/openpilot/tools/cabana/chart/chart.cc +++ /dev/null @@ -1,769 +0,0 @@ -#include "tools/cabana/chart/chart.h" - -#include -#include -#include - -#include -#include -#include -#include - -#include "tools/cabana/chart/chartswidget.h" - -const int AXIS_X_TOP_MARGIN = 4; -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) - : x_min(x_range.first), x_max(x_range.second), charts_widget(parent), QWidget(parent) { - series_type = (SeriesType)settings.chart_series_type; - align_to = 50; - setMouseTracking(true); - tip_label = new TipLabel(this); - createToolButtons(); - signal_value_font.setPointSize(9); - - connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { signalRemoved(sig); })); - connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { signalUpdated(sig); })); - connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { msgRemoved(id); })); - connections_.push_back(dbc()->msgUpdated.connect([this](MessageId id) { msgUpdated(id); })); -} - -void ChartView::createToolButtons() { - close_btn = new ToolButton("x", tr("Remove Chart"), this); - - menu = new QMenu(this); - // series types - auto change_series_group = new QActionGroup(menu); - change_series_group->setExclusive(true); - QStringList types{tr("Line"), tr("Step Line"), tr("Scatter")}; - for (int i = 0; i < types.size(); ++i) { - QAction *act = new QAction(types[i], change_series_group); - act->setData(i); - act->setCheckable(true); - act->setChecked(i == (int)series_type); - menu->addAction(act); - } - menu->addSeparator(); - menu->addAction(tr("Manage Signals"), this, &ChartView::manageSignals); - split_chart_act = menu->addAction(tr("Split Chart"), [this]() { charts_widget->splitChart(this); }); - - manage_btn = new ToolButton("list", "", this); - manage_btn->setMenu(menu); - manage_btn->setPopupMode(QToolButton::InstantPopup); - manage_btn->setStyleSheet("QToolButton::menu-indicator { image: none; }"); - - close_act = new QAction(tr("Close"), this); - QObject::connect(close_act, &QAction::triggered, [this] () { charts_widget->removeChart(this); }); - 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()); - }); -} - -QSize ChartView::sizeHint() const { - return {CHART_MIN_WIDTH, settings.chart_height}; -} - -void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) { - if (hasSignal(msg_id, sig)) return; - - sigs.push_back({.msg_id = msg_id, .sig = sig, .color = uniqueColor(toQColor(sig->color))}); - updateSeries(sig); - updateTitle(); - emit charts_widget->seriesChanged(); -} - -bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const { - return std::any_of(sigs.cbegin(), sigs.cend(), [&](auto &s) { return s.msg_id == msg_id && s.sig == sig; }); -} - -void ChartView::removeIf(std::function predicate) { - int prev_size = sigs.size(); - 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(); - 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->color != toQColor(sig->color)) { - it->color = uniqueColor(toQColor(sig->color), sig); - } - updateTitle(); - updateSeries(sig); - } -} - -void ChartView::msgUpdated(MessageId id) { - if (std::any_of(sigs.cbegin(), sigs.cend(), [=](auto &s) { return s.msg_id.address == id.address; })) { - updateTitle(); - } -} - -void ChartView::manageSignals() { - SignalSelector dlg(tr("Manage Chart"), this); - for (auto &s : sigs) { - dlg.addSelected(s.msg_id, s.sig); - } - if (dlg.exec() == QDialog::Accepted) { - auto items = dlg.seletedItems(); - for (auto s : items) { - addSignal(s->msg_id, s->sig); - } - removeIf([&](auto &s) { - return std::none_of(items.cbegin(), items.cend(), [&](auto &it) { return s.msg_id == it->msg_id && s.sig == it->sig; }); - }); - } -} - -void ChartView::resizeEvent(QResizeEvent *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; - - 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); - - // 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 = 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() { - split_chart_act->setEnabled(sigs.size() > 1); - updatePlotArea(align_to, true); -} - -void ChartView::updatePlot(double cur, double min, double max) { - cur_sec = cur; - if (min != x_min || max != x_max) { - x_min = min; - x_max = max; - updateAxisY(); - // update tooltip - if (tooltip_x >= 0) { - showTip(secondsAtPoint({tooltip_x, 0})); - } - resetChartCache(); - } - update(); -} - -void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector &events, - std::vector &vals, std::vector &step_vals) { - vals.reserve(vals.size() + events.capacity()); - step_vals.reserve(step_vals.size() + events.capacity() * 2); - - double value = 0; - for (const CanEvent *e : events) { - if (sig->getValue(e->dat, e->size, &value)) { - const double ts = can->toSeconds(e->mono_time); - vals.emplace_back(ts, value); - if (!step_vals.empty()) - step_vals.emplace_back(ts, step_vals.back().y()); - step_vals.emplace_back(ts, value); - } - } -} - -void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap *msg_new_events) { - for (auto &s : sigs) { - if (!sig || s.sig == sig) { - if (!msg_new_events) { - s.vals.clear(); - s.step_vals.clear(); - } - auto events = msg_new_events ? msg_new_events : &can->eventsMap(); - auto it = events->find(s.msg_id); - if (it == events->end() || it->second.empty()) continue; - - if (s.vals.empty() || can->toSeconds(it->second.back()->mono_time) > s.vals.back().x()) { - appendCanEvents(s.sig, it->second, s.vals, s.step_vals); - } else { - std::vector vals, step_vals; - appendCanEvents(s.sig, it->second, vals, step_vals); - s.vals.insert(std::lower_bound(s.vals.begin(), s.vals.end(), vals.front().x(), xLessThan), - vals.begin(), vals.end()); - s.step_vals.insert(std::lower_bound(s.step_vals.begin(), s.step_vals.end(), step_vals.front().x(), xLessThan), - step_vals.begin(), step_vals.end()); - } - - if (!can->liveStreaming()) { - s.segment_tree.build(s.vals.size(), [&vals = s.vals](int i) { return vals[i].y(); }); - } - } - } - updateAxisY(); - // invoke resetChartCache in ui thread - QMetaObject::invokeMethod(this, &ChartView::resetChartCache, Qt::QueuedConnection); -} - -// auto zoom on yaxis -void ChartView::updateAxisY() { - if (sigs.empty()) return; - - double min = std::numeric_limits::max(); - double max = std::numeric_limits::lowest(); - QString unit = QString::fromStdString(sigs[0].sig->unit); - - for (auto &s : sigs) { - if (!s.visible) continue; - - // Only show unit when all signals have the same unit - if (unit != QString::fromStdString(s.sig->unit)) { - unit.clear(); - } - - 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()) { - for (auto it = first; it != last; ++it) { - if (it->y() < s.min) s.min = it->y(); - if (it->y() > s.max) s.max = it->y(); - } - } else { - std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last)); - } - min = std::min(min, s.min); - max = std::max(max, s.max); - } - if (min == std::numeric_limits::max()) min = 0; - if (max == std::numeric_limits::lowest()) max = 0; - - 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 != 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); - - QFontMetrics fm(font()); - int max_label_width = 0; - 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', y_precision))); - } - - int title_spacing = y_unit.isEmpty() ? 0 : fm.size(Qt::TextSingleLine, y_unit).height(); - y_label_width = title_spacing + max_label_width + 15; - emit axisYLabelWidthChanged(y_label_width); - } -} - -std::tuple ChartView::getNiceAxisNumbers(qreal min, qreal max, int tick_count) { - qreal range = niceNumber((max - min), true); // range with ceiling - qreal step = niceNumber(range / (tick_count - 1), false); - min = std::floor(min / step); - max = std::ceil(max / step); - tick_count = int(max - min) + 1; - 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 - qreal q = x / z; //q<10 && q>=1; - if (ceiling) { - if (q <= 1.0) q = 1; - else if (q <= 2.0) q = 2; - else if (q <= 5.0) q = 5; - else q = 10; - } else { - if (q < 1.5) q = 1; - else if (q < 3.0) q = 2; - else if (q < 7.0) q = 5; - else q = 10; - } - return q * z; -} - -void ChartView::contextMenuEvent(QContextMenuEvent *event) { - QMenu context_menu(this); - context_menu.addActions(menu->actions()); - context_menu.addSeparator(); - context_menu.addAction(charts_widget->undo_zoom_action); - context_menu.addAction(charts_widget->redo_zoom_action); - context_menu.addSeparator(); - context_menu.addAction(close_act); - context_menu.exec(event->globalPos()); -} - -void ChartView::mousePressEvent(QMouseEvent *event) { - 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); - } - mouse_mode = MouseMode::Scrub; - } else if (event->button() == Qt::LeftButton && plot_area.contains(event->pos())) { - mouse_mode = MouseMode::Rubber; - rubber_rect = QRect(); - } else { - QWidget::mousePressEvent(event); - } -} - -void ChartView::mouseMoveEvent(QMouseEvent *ev) { - // Scrubbing - if (mouse_mode == MouseMode::Scrub && ev->modifiers().testFlag(Qt::ShiftModifier)) { - if (plot_area.contains(ev->pos())) { - can->seekTo(std::clamp(secondsAtPoint(ev->pos()), can->minSeconds(), can->maxSeconds())); - } - } - - 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(); - } - - 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); -} - -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; - } - } -} - -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, 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 = xPos(sec); - qreal x = -1; - QStringList text_list; - for (auto &s : sigs) { - 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 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, xPos(it->x())); - } - 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.color.name(), name, value, min, max); - } - } - if (x < 0) { - x = tooltip_x; - } - 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); - update(); -} - -void ChartView::hideTip() { - clearTrackPoints(); - tooltip_x = -1; - tip_label->hide(); - update(); -} - -void ChartView::resetChartCache() { - chart_pixmap = QPixmap(); - update(); -} - -void ChartView::paintEvent(QPaintEvent *event) { - QPainter painter(this); - painter.setRenderHints(QPainter::Antialiasing); - - // 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::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::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.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) { - painter->setPen(QPen(Qt::darkGray, 1, Qt::DashLine)); - 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) { - 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) { - // draw vertical time line - 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(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(font()); - painter->drawText(time_str_rect, Qt::AlignCenter, time_str); -} - -void ChartView::drawSignalValue(QPainter *painter) { - painter->setFont(signal_value_font); - 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() >= 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); - } -} - -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 = 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), - sat(rng) / 100.0, - val(rng) / 100.0); - break; - } - } - return color; -} - -void ChartView::setSeriesType(SeriesType type) { - if (type != series_type) { - series_type = type; - menu->actions()[(int)type]->setChecked(true); - updateTitle(); - } -} diff --git a/openpilot/tools/cabana/chart/chart.h b/openpilot/tools/cabana/chart/chart.h deleted file mode 100644 index 3188e1f401..0000000000 --- a/openpilot/tools/cabana/chart/chart.h +++ /dev/null @@ -1,132 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include - -#include "tools/cabana/chart/tiplabel.h" -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/utils/qtutil.h" - -enum class SeriesType { - Line = 0, - StepLine, - Scatter -}; - -class ChartsWidget; -class ChartView : public QWidget { - Q_OBJECT - -public: - ChartView(const std::pair &x_range, ChartsWidget *parent = nullptr); - void addSignal(const MessageId &msg_id, const cabana::Signal *sig); - bool hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const; - void updateSeries(const cabana::Signal *sig = nullptr, const MessageEventsMap *msg_new_events = nullptr); - void updatePlot(double cur, double min, double max); - void setSeriesType(SeriesType type); - void updatePlotArea(int left, bool force = false); - void showTip(double sec); - void hideTip(); - 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; - QColor color; - bool visible = true; - std::vector vals; - std::vector step_vals; - QPointF track_pt{}; - SegmentTree segment_tree; - double min = 0; - double max = 0; - }; - -signals: - void axisYLabelWidthChanged(int w); - -private slots: - void signalUpdated(const cabana::Signal *sig); - void manageSignals(); - 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; }); } - -private: - void appendCanEvents(const cabana::Signal *sig, const std::vector &events, - std::vector &vals, std::vector &step_vals); - void createToolButtons(); - void contextMenuEvent(QContextMenuEvent *event) override; - void mousePressEvent(QMouseEvent *event) override; - void mouseMoveEvent(QMouseEvent *event) override; - void mouseReleaseEvent(QMouseEvent *event) override; - void resizeEvent(QResizeEvent *event) override; - QSize sizeHint() const override; - void updateAxisY(); - void updateTitle(); - void resetChartCache(); - void paintEvent(QPaintEvent *event) override; - 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); - 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; - // 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; - ToolButton *manage_btn; - ToolButton *close_btn; - TipLabel *tip_label; - std::vector sigs; - double cur_sec = 0; - SeriesType series_type = SeriesType::Line; - QPixmap chart_pixmap; - bool can_drop = false; - double tooltip_x = -1; - QFont signal_value_font; - ChartsWidget *charts_widget; - Connections connections_; - friend class ChartsWidget; -}; diff --git a/openpilot/tools/cabana/chart/chartswidget.cc b/openpilot/tools/cabana/chart/chartswidget.cc deleted file mode 100644 index 2e3e86bab0..0000000000 --- a/openpilot/tools/cabana/chart/chartswidget.cc +++ /dev/null @@ -1,660 +0,0 @@ -#include "tools/cabana/chart/chartswidget.h" - -#include -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/chart/chart.h" - -const int MAX_COLUMN_COUNT = 4; -const int CHART_SPACING = 4; - -ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { - align_timer = new QTimer(this); - auto_scroll_timer = new QTimer(this); - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - - // toolbar - toolbar = new QToolBar(tr("Charts"), this); - int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize); - toolbar->setIconSize({icon_size, icon_size}); - - auto new_plot_btn = new ToolButton("file-plus", tr("New Chart")); - auto new_tab_btn = new ToolButton("window-stack", tr("New Tab")); - toolbar->addWidget(new_plot_btn); - toolbar->addWidget(new_tab_btn); - toolbar->addWidget(title_label = new QLabel()); - title_label->setContentsMargins(0, 0, style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing), 0); - - auto chart_type_action = toolbar->addAction(""); - QMenu *chart_type_menu = new QMenu(this); - auto types = std::array{tr("Line"), tr("Step"), tr("Scatter")}; - for (int i = 0; i < types.size(); ++i) { - QString type_text = types[i]; - chart_type_menu->addAction(type_text, this, [=]() { - settings.chart_series_type = i; - chart_type_action->setText("Type: " + type_text); - settingChanged(); - }); - } - chart_type_action->setText("Type: " + types[settings.chart_series_type]); - chart_type_action->setMenu(chart_type_menu); - qobject_cast(toolbar->widgetForAction(chart_type_action))->setPopupMode(QToolButton::InstantPopup); - - QMenu *menu = new QMenu(this); - for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { - menu->addAction(tr("%1").arg(i + 1), [=]() { setColumnCount(i + 1); }); - } - columns_action = toolbar->addAction(""); - columns_action->setMenu(menu); - qobject_cast(toolbar->widgetForAction(columns_action))->setPopupMode(QToolButton::InstantPopup); - - QWidget *spacer = new QWidget(this); - spacer->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); - toolbar->addWidget(spacer); - - range_lb_action = toolbar->addWidget(range_lb = new QLabel(this)); - range_slider = new LogSlider(1000, Qt::Horizontal, this); - range_slider->setFixedWidth(150 * qApp->devicePixelRatio()); - range_slider->setToolTip(tr("Set the chart range")); - range_slider->setRange(1, settings.max_cached_minutes * 60); - range_slider->setSingleStep(1); - range_slider->setPageStep(60); // 1 min - range_slider_action = toolbar->addWidget(range_slider); - - // zoom controls - 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); - connections_.push_back(zoom_undo_stack.indexChanged.connect([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); - - toolbar->addWidget(remove_all_btn = new ToolButton("x-square", tr("Remove all charts"))); - toolbar->addWidget(dock_btn = new ToolButton("")); - main_layout->addWidget(toolbar); - - // tabbar - tabbar = new TabBar(this); - tabbar->setAutoHide(true); - tabbar->setExpanding(false); - tabbar->setDrawBase(true); - tabbar->setUsesScrollButtons(true); - main_layout->addWidget(tabbar); - - // charts - charts_container = new ChartsContainer(this); - charts_container->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); - charts_scroll = new QScrollArea(this); - charts_scroll->viewport()->setBackgroundRole(QPalette::Base); - charts_scroll->setFrameStyle(QFrame::NoFrame); - charts_scroll->setWidgetResizable(true); - charts_scroll->setWidget(charts_container); - 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); - max_chart_range = std::clamp(settings.chart_range, 1, settings.max_cached_minutes * 60); - display_range = std::make_pair(can->minSeconds(), can->minSeconds() + max_chart_range); - range_slider->setValue(max_chart_range); - updateToolBar(); - - align_timer->setSingleShot(true); - QObject::connect(align_timer, &QTimer::timeout, this, &ChartsWidget::alignCharts); - QObject::connect(auto_scroll_timer, &QTimer::timeout, this, &ChartsWidget::doAutoScroll); - connections_.push_back(dbc()->fileChanged.connect([this]() { removeAll(); })); - connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &events) { eventsMerged(events); })); - connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); - connections_.push_back(can->seeking.connect([this](double) { updateState(); })); - connections_.push_back(can->timeRangeChanged.connect([this](const auto &range) { timeRangeChanged(range); })); - QObject::connect(range_slider, &QSlider::valueChanged, this, &ChartsWidget::setMaxChartRange); - QObject::connect(new_plot_btn, &QToolButton::clicked, this, &ChartsWidget::newChart); - QObject::connect(remove_all_btn, &QToolButton::clicked, this, &ChartsWidget::removeAll); - QObject::connect(reset_zoom_btn, &QToolButton::clicked, this, &ChartsWidget::zoomReset); - connections_.push_back(settings.changed.connect([this]() { settingChanged(); })); - QObject::connect(new_tab_btn, &QToolButton::clicked, this, &ChartsWidget::newTab); - QObject::connect(this, &ChartsWidget::seriesChanged, this, &ChartsWidget::updateTabBar); - QObject::connect(tabbar, &QTabBar::tabCloseRequested, this, &ChartsWidget::removeTab); - QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) { - if (index != -1) updateLayout(true); - }); - QObject::connect(dock_btn, &QToolButton::clicked, this, &ChartsWidget::toggleChartsDocking); - - setIsDocked(true); - newTab(); - qApp->installEventFilter(this); - setWhatsThis(tr(R"( - Chart View
- Click: Click to seek to a corresponding time.
- Drag: Zoom into the chart.
- Shift + Drag: Scrub through the chart to view values.
- Right Mouse: Open the context menu.
- )")); -} - -void ChartsWidget::newTab() { - static int tab_unique_id = 0; - int idx = tabbar->addTab(""); - tabbar->setTabData(idx, tab_unique_id++); - tabbar->setCurrentIndex(idx); - updateTabBar(); -} - -void ChartsWidget::removeTab(int index) { - int id = tabbar->tabData(index).toInt(); - for (auto &c : tab_charts[id]) { - removeChart(c); - } - tab_charts.erase(id); - tabbar->removeTab(index); - updateTabBar(); -} - -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((int)charts_in_tab.size())); - } -} - -void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) { - std::vector> futures; - for (auto c : charts) { - 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) { - updateToolBar(); - updateState(); -} - -void ChartsWidget::zoomReset() { - can->setTimeRange(std::nullopt); - zoom_undo_stack.clear(); -} - -QRect ChartsWidget::chartVisibleRect(ChartView *chart) { - const QRect visible_rect(-charts_container->pos(), charts_scroll->viewport()->size()); - return chart->rect().intersected(QRect(chart->mapFrom(charts_container, visible_rect.topLeft()), visible_rect.size())); -} - -void ChartsWidget::showValueTip(double sec) { - emit showTip(sec); - if (sec < 0 && !value_tip_visible_) return; - - value_tip_visible_ = sec >= 0; - for (auto c : currentCharts()) { - value_tip_visible_ ? c->showTip(sec) : c->hideTip(); - } -} - -void ChartsWidget::updateState() { - if (charts.empty()) return; - - const auto &time_range = can->timeRange(); - const double cur_sec = can->currentSec(); - if (!time_range.has_value()) { - double pos = (cur_sec - display_range.first) / std::max(1.0, max_chart_range); - if (pos < 0 || pos > 0.8) { - display_range.first = std::max(can->minSeconds(), cur_sec - max_chart_range * 0.1); - } - double max_sec = std::min(display_range.first + max_chart_range, can->maxSeconds()); - display_range.first = std::max(can->minSeconds(), max_sec - max_chart_range); - display_range.second = display_range.first + max_chart_range; - } - - const auto &range = time_range ? *time_range : display_range; - for (auto c : charts) { - c->updatePlot(cur_sec, range.first, range.second); - } -} - -void ChartsWidget::setMaxChartRange(int value) { - max_chart_range = settings.chart_range = range_slider->value(); - updateToolBar(); - updateState(); -} - -void ChartsWidget::setIsDocked(bool docked) { - is_docked = docked; - dock_btn->setIcon(is_docked ? "arrow-up-right-square" : "arrow-down-left-square"); - dock_btn->setToolTip(is_docked ? tr("Float the charts window") : tr("Dock the charts window")); -} - -void ChartsWidget::updateToolBar() { - title_label->setText(tr("Charts: %1").arg(charts.size())); - columns_action->setText(tr("Columns: %1").arg(column_count)); - range_lb->setText(QString::fromStdString(utils::formatSeconds(max_chart_range))); - - bool is_zoomed = can->timeRange().has_value(); - range_lb_action->setVisible(!is_zoomed); - range_slider_action->setVisible(!is_zoomed); - undo_zoom_action->setVisible(is_zoomed); - 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.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")); - } - if (range_slider->maximum() != settings.max_cached_minutes * 60) { - range_slider->setRange(1, settings.max_cached_minutes * 60); - } - for (auto c : charts) { - c->setFixedHeight(settings.chart_height); - c->setSeriesType((SeriesType)settings.chart_series_type); - c->resetChartCache(); - } -} - -ChartView *ChartsWidget::findChart(const MessageId &id, const cabana::Signal *sig) { - for (auto c : charts) - if (c->hasSignal(id, sig)) return c; - return nullptr; -} - -ChartView *ChartsWidget::createChart(int pos) { - auto chart = new ChartView(can->timeRange().value_or(display_range), this); - chart->setFixedHeight(settings.chart_height); - 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, (int)charts.size()); - charts.insert(charts.begin() + pos, chart); - currentCharts().insert(currentCharts().begin() + pos, chart); - updateLayout(true); - updateToolBar(); - return chart; -} - -void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { - ChartView *chart = findChart(id, sig); - if (show && !chart) { - chart = merge && currentCharts().size() > 0 ? currentCharts().front() : createChart(); - chart->addSignal(id, sig); - updateState(); - } else if (!show && chart) { - chart->removeIf([&](auto &s) { return s.msg_id == id && s.sig == sig; }); - } -} - -void ChartsWidget::splitChart(ChartView *src_chart) { - if (src_chart->sigs.size() > 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); - // Restore to the original color - it->color = toQColor(it->sig->color); - c->sigs.emplace_back(std::move(*it)); - c->updateAxisY(); - c->updateTitle(); - it = src_chart->sigs.erase(it); - } - src_chart->updateAxisY(); - src_chart->updateTitle(); - updateState(); - QTimer::singleShot(0, src_chart, &ChartView::resetChartCache); - } -} - -QStringList ChartsWidget::serializeChartIds() const { - QStringList chart_ids; - for (auto c : charts) { - QStringList ids; - for (const auto& s : c->sigs) - 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()); - return chart_ids; -} - -void ChartsWidget::restoreChartsFromIds(const QStringList& chart_ids) { - for (const auto& chart_id : chart_ids) { - int index = 0; - 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].toStdString()); - if (auto* msg = dbc()->msg(msg_id)) - if (auto* sig = msg->sig(sig_parts[1].toStdString())) - showChart(msg_id, sig, true, index++ > 0); - } - } -} - -void ChartsWidget::setColumnCount(int n) { - n = std::clamp(n, 1, MAX_COLUMN_COUNT); - if (column_count != n) { - column_count = settings.chart_column_count = n; - updateToolBar(); - updateLayout(); - } -} - -void ChartsWidget::updateLayout(bool force) { - auto charts_layout = charts_container->charts_layout; - int n = MAX_COLUMN_COUNT; - for (; n > 1; --n) { - if ((n * CHART_MIN_WIDTH + (n - 1) * charts_layout->horizontalSpacing()) < charts_layout->geometry().width()) break; - } - - bool show_column_cb = n > 1; - columns_action->setVisible(show_column_cb); - - n = std::min(column_count, n); - auto ¤t_charts = currentCharts(); - if ((current_charts.size() != charts_layout->count() || n != current_column_count) || force) { - current_column_count = n; - charts_container->setUpdatesEnabled(false); - for (auto c : charts) { - c->setVisible(false); - } - for (int i = 0; i < current_charts.size(); ++i) { - charts_layout->addWidget(current_charts[i], i / n, i % n); - if (current_charts[i]->sigs.empty()) { - // the chart will be resized after add signal. delay setVisible to reduce flicker. - QTimer::singleShot(0, current_charts[i], [c = current_charts[i]]() { c->setVisible(true); }); - } else { - current_charts[i]->setVisible(true); - } - } - charts_container->setUpdatesEnabled(true); - } -} - -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); -} - -void ChartsWidget::stopAutoScroll() { - auto_scroll_timer->stop(); - auto_scroll_count = 0; -} - -void ChartsWidget::doAutoScroll() { - QScrollBar *scroll = charts_scroll->verticalScrollBar(); - if (auto_scroll_count < scroll->pageStep()) { - ++auto_scroll_count; - } - - int value = scroll->value(); - QPoint pos = charts_scroll->viewport()->mapFromGlobal(auto_scroll_pos); - QRect area = charts_scroll->viewport()->rect(); - - if (pos.y() - area.top() < settings.chart_height / 2) { - scroll->setValue(value - auto_scroll_count); - } else if (area.bottom() - pos.y() < settings.chart_height / 2) { - scroll->setValue(value + auto_scroll_count); - } - if (value == scroll->value()) { - stopAutoScroll(); - } 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, QWidget::minimumSizeHint().height()); -} - -void ChartsWidget::newChart() { - SignalSelector dlg(tr("New Chart"), this); - if (dlg.exec() == QDialog::Accepted) { - auto items = dlg.seletedItems(); - if (!items.empty()) { - auto c = createChart(); - for (auto it : items) { - c->addSignal(it->msg_id, it->sig); - } - updateState(); - } - } -} - -void ChartsWidget::removeChart(ChartView *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.erase(std::remove(list.begin(), list.end(), chart), list.end()); - } - updateToolBar(); - updateLayout(true); - alignCharts(); - emit seriesChanged(); -} - -void ChartsWidget::removeAll() { - while (tabbar->count() > 1) { - tabbar->removeTab(1); - } - tab_charts.clear(); - - if (!charts.empty()) { - for (auto c : charts) { - delete c; - } - charts.clear(); - emit seriesChanged(); - } - zoomReset(); -} - -void ChartsWidget::alignCharts() { - int plot_left = 0; - for (auto c : charts) { - plot_left = std::max(plot_left, c->y_label_width); - } - plot_left = std::max((plot_left / 10) * 10 + 10, 50); - for (auto c : charts) { - c->updatePlotArea(plot_left); - } -} - -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) { - bool on_tip = qobject_cast(o) != nullptr; - auto global_pos = static_cast(e)->globalPos(); - - for (const auto &c : charts) { - auto local_pos = c->mapFromGlobal(global_pos); - if (c->plot_area.contains(local_pos)) { - if (on_tip) { - showValueTip(c->secondsAtPoint(local_pos)); - } - return false; - } - } - - showValueTip(-1); - } else if (e->type() == QEvent::Wheel) { - if (auto tip = qobject_cast(o)) { - // Forward the event to the parent widget - QCoreApplication::sendEvent(tip->parentWidget(), e); - } - } - return false; -} - -bool ChartsWidget::event(QEvent *event) { - bool back_button = false; - switch (event->type()) { - case QEvent::Resize: - updateLayout(); - break; - case QEvent::MouseButtonPress: - back_button = static_cast(event)->button() == Qt::BackButton; - break; - case QEvent::NativeGesture: - back_button = (static_cast(event)->value() == 180); - break; - case QEvent::WindowDeactivate: - case QEvent::FocusOut: - if (chartDragActive()) cancelChartDrag(); - showValueTip(-1); - default: - break; - } - - if (back_button) { - zoom_undo_stack.undo(); - return true; // Return true since the event has been handled - } - return QFrame::event(event); -} - -// ChartsContainer - -ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), QWidget(parent) { - setBackgroundRole(QPalette::Window); - QVBoxLayout *charts_main_layout = new QVBoxLayout(this); - charts_main_layout->setContentsMargins(0, CHART_SPACING, 0, CHART_SPACING); - charts_layout = new QGridLayout(); - charts_layout->setSpacing(CHART_SPACING); - charts_main_layout->addLayout(charts_layout); - charts_main_layout->addStretch(0); -} - -void ChartsContainer::paintEvent(QPaintEvent *ev) { - if (!drop_indictor_pos.isNull() && !childAt(drop_indictor_pos)) { - QRect r = geometry(); - r.setHeight(CHART_SPACING); - if (auto insert_after = getDropAfter(drop_indictor_pos)) { - r.moveTop(insert_after->geometry().bottom()); - } - - QPainter p(this); - p.fillRect(r, palette().highlight()); - } -} - -ChartView *ChartsContainer::getDropAfter(const QPoint &pos) const { - auto it = std::find_if(charts_widget->currentCharts().crbegin(), charts_widget->currentCharts().crend(), [&pos](auto c) { - auto area = c->geometry(); - return pos.x() >= area.left() && pos.x() <= area.right() && pos.y() >= area.bottom(); - }); - return it == charts_widget->currentCharts().crend() ? nullptr : *it; -} diff --git a/openpilot/tools/cabana/chart/chartswidget.h b/openpilot/tools/cabana/chart/chartswidget.h deleted file mode 100644 index 17e18a90d2..0000000000 --- a/openpilot/tools/cabana/chart/chartswidget.h +++ /dev/null @@ -1,140 +0,0 @@ -#pragma once - -#include -#include - -#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" -#include "tools/cabana/utils/qtutil.h" - -const int CHART_MIN_WIDTH = 300; - -class ChartView; -class ChartsWidget; - -class ChartsContainer : public QWidget { -public: - ChartsContainer(ChartsWidget *parent); - void drawDropIndicator(const QPoint &pt) { drop_indictor_pos = pt; update(); } - void paintEvent(QPaintEvent *ev) override; - ChartView *getDropAfter(const QPoint &pos) const; - - QGridLayout *charts_layout; - ChartsWidget *charts_widget; - QPoint drop_indictor_pos; -}; - -class ChartsWidget : public QFrame { - Q_OBJECT - -public: - ChartsWidget(QWidget *parent = nullptr); - void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); - inline bool hasSignal(const MessageId &id, const cabana::Signal *sig) { return findChart(id, sig) != nullptr; } - QStringList serializeChartIds() const; - void restoreChartsFromIds(const QStringList &chart_ids); - -public slots: - void setColumnCount(int n); - void removeAll(); - void timeRangeChanged(const std::optional> &time_range); - void setIsDocked(bool dock); - -signals: - void toggleChartsDocking(); - void seriesChanged(); - void showTip(double seconds); - -private: - QSize minimumSizeHint() const override; - bool event(QEvent *event) override; - void alignCharts(); - void newChart(); - ChartView *createChart(int pos = 0); - void removeChart(ChartView *chart); - void splitChart(ChartView *chart); - QRect chartVisibleRect(ChartView *chart); - void eventsMerged(const MessageEventsMap &new_events); - void updateState(); - void zoomReset(); - 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(); - void updateTabBar(); - void setMaxChartRange(int value); - void updateLayout(bool force = false); - void settingChanged(); - void showValueTip(double sec); - bool eventFilter(QObject *obj, QEvent *event) override; - void newTab(); - void removeTab(int index); - inline std::vector ¤tCharts() { return tab_charts[tabbar->tabData(tabbar->currentIndex()).toInt()]; } - ChartView *findChart(const MessageId &id, const cabana::Signal *sig); - - QLabel *title_label; - QLabel *range_lb; - LogSlider *range_slider; - QAction *range_lb_action; - QAction *range_slider_action; - bool is_docked = true; - ToolButton *dock_btn; - - QToolBar *toolbar; - QAction *undo_zoom_action; - QAction *redo_zoom_action; - QAction *reset_zoom_action; - ToolButton *reset_zoom_btn; - UndoStack zoom_undo_stack; - - ToolButton *remove_all_btn; - std::vector charts; - std::unordered_map> tab_charts; - TabBar *tabbar; - ChartsContainer *charts_container; - QScrollArea *charts_scroll; - uint32_t max_chart_range = 0; - std::pair display_range; - 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; - bool value_tip_visible_ = false; - Connections connections_; - friend class ChartView; - friend class ChartsContainer; -}; - -class ZoomCommand : public UndoCommand { -public: - ZoomCommand(std::pair range) : range(range) { - prev_range = can->timeRange(); - } - void undo() override { can->setTimeRange(prev_range); } - void redo() override { can->setTimeRange(range); } - std::optional> prev_range, range; -}; diff --git a/openpilot/tools/cabana/chart/signalselector.cc b/openpilot/tools/cabana/chart/signalselector.cc deleted file mode 100644 index 90825dc402..0000000000 --- a/openpilot/tools/cabana/chart/signalselector.cc +++ /dev/null @@ -1,107 +0,0 @@ -#include "tools/cabana/chart/signalselector.h" - -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/utils/qtutil.h" - -SignalSelector::SignalSelector(QString title, QWidget *parent) : QDialog(parent) { - setWindowTitle(title); - QGridLayout *main_layout = new QGridLayout(this); - - // left column - main_layout->addWidget(new QLabel(tr("Available Signals")), 0, 0); - main_layout->addWidget(msgs_combo = new QComboBox(this), 1, 0); - msgs_combo->setEditable(true); - msgs_combo->lineEdit()->setPlaceholderText(tr("Select a msg...")); - msgs_combo->setInsertPolicy(QComboBox::NoInsert); - - main_layout->addWidget(available_list = new QListWidget(this), 2, 0); - - // buttons - QVBoxLayout *btn_layout = new QVBoxLayout(); - QPushButton *add_btn = new QPushButton(utils::icon("chevron-right"), "", this); - add_btn->setEnabled(false); - QPushButton *remove_btn = new QPushButton(utils::icon("chevron-left"), "", this); - remove_btn->setEnabled(false); - btn_layout->addStretch(0); - btn_layout->addWidget(add_btn); - btn_layout->addWidget(remove_btn); - btn_layout->addStretch(0); - main_layout->addLayout(btn_layout, 0, 1, 3, 1); - - // right column - main_layout->addWidget(new QLabel(tr("Selected Signals")), 0, 2); - main_layout->addWidget(selected_list = new QListWidget(this), 1, 2, 2, 1); - - auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - main_layout->addWidget(buttonBox, 3, 2); - - for (const auto &[id, _] : can->lastMessages()) { - if (auto m = dbc()->msg(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); - msgs_combo->setCurrentIndex(-1); - - QObject::connect(msgs_combo, qOverload(&QComboBox::currentIndexChanged), this, &SignalSelector::updateAvailableList); - QObject::connect(available_list, &QListWidget::currentRowChanged, [=](int row) { add_btn->setEnabled(row != -1); }); - QObject::connect(selected_list, &QListWidget::currentRowChanged, [=](int row) { remove_btn->setEnabled(row != -1); }); - QObject::connect(available_list, &QListWidget::itemDoubleClicked, this, &SignalSelector::add); - QObject::connect(selected_list, &QListWidget::itemDoubleClicked, this, &SignalSelector::remove); - QObject::connect(add_btn, &QPushButton::clicked, [this]() { if (auto item = available_list->currentItem()) add(item); }); - QObject::connect(remove_btn, &QPushButton::clicked, [this]() { if (auto item = selected_list->currentItem()) remove(item); }); - QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); - QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); -} - -void SignalSelector::add(QListWidgetItem *item) { - auto it = (ListItem *)item; - addItemToList(selected_list, it->msg_id, it->sig, true); - delete item; -} - -void SignalSelector::remove(QListWidgetItem *item) { - auto it = (ListItem *)item; - if (it->msg_id == msgs_combo->currentData().value()) { - addItemToList(available_list, it->msg_id, it->sig); - } - delete item; -} - -void SignalSelector::updateAvailableList(int index) { - if (index == -1) return; - available_list->clear(); - MessageId msg_id = msgs_combo->itemData(index).value(); - auto selected_items = seletedItems(); - for (auto s : dbc()->msg(msg_id)->getSignals()) { - bool is_selected = std::any_of(selected_items.begin(), selected_items.end(), - [sig = s, &msg_id](auto it) { return it->msg_id == msg_id && it->sig == sig; }); - if (!is_selected) { - addItemToList(available_list, msg_id, s); - } - } -} - -void SignalSelector::addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name) { - 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); - auto new_item = new ListItem(id, sig, parent); - new_item->setSizeHint(label->sizeHint()); - parent->setItemWidget(new_item, label); -} - -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/openpilot/tools/cabana/chart/signalselector.h b/openpilot/tools/cabana/chart/signalselector.h deleted file mode 100644 index 5b6e37e56a..0000000000 --- a/openpilot/tools/cabana/chart/signalselector.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" - -class SignalSelector : public QDialog { -public: - struct ListItem : public QListWidgetItem { - ListItem(const MessageId &msg_id, const cabana::Signal *sig, QListWidget *parent) : msg_id(msg_id), sig(sig), QListWidgetItem(parent) {} - MessageId msg_id; - const cabana::Signal *sig; - }; - - SignalSelector(QString title, QWidget *parent); - std::vector seletedItems(); - inline void addSelected(const MessageId &id, const cabana::Signal *sig) { addItemToList(selected_list, id, sig, true); } - -private: - void updateAvailableList(int index); - void addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name = false); - void add(QListWidgetItem *item); - void remove(QListWidgetItem *item); - - QComboBox *msgs_combo; - QListWidget *available_list; - QListWidget *selected_list; -}; diff --git a/openpilot/tools/cabana/chart/sparkline.cc b/openpilot/tools/cabana/chart/sparkline.cc deleted file mode 100644 index 587a35956d..0000000000 --- a/openpilot/tools/cabana/chart/sparkline.cc +++ /dev/null @@ -1,101 +0,0 @@ -#include "tools/cabana/chart/sparkline.h" - -#include -#include -#include -#include "tools/cabana/utils/qtutil.h" - -void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, QSize size) { - if (first == last || size.isEmpty()) { - pixmap = QPixmap(); - return; - } - - points_.clear(); - min_val = std::numeric_limits::max(); - max_val = std::numeric_limits::lowest(); - points_.reserve(std::distance(first, last)); - - uint64_t start_time = (*first)->mono_time; - double value = 0.0; - for (auto it = first; it != last; ++it) { - if (sig->getValue((*it)->dat, (*it)->size, &value)) { - min_val = std::min(min_val, value); - max_val = std::max(max_val, value); - points_.emplace_back(((*it)->mono_time - start_time) / 1e9, value); - } - } - - if (points_.empty()) { - pixmap = QPixmap(); - return; - } - - freq_ = points_.size() / std::max(points_.back().x() - points_.front().x(), 1.0); - render(toQColor(sig->color), range, size); -} - -void Sparkline::render(const QColor &color, int range, QSize size) { - // Adjust for flat lines - bool is_flat_line = min_val == max_val; - if (is_flat_line) { - min_val -= 1.0; - max_val += 1.0; - } - - // Calculate scaling - const double xscale = (size.width() - 1) / (double)range; - const double yscale = (size.height() - 3) / (max_val - min_val); - bool draw_individual_points = (points_.back().x() * xscale / points_.size()) > 8.0; - - // Transform or downsample points - render_points_.reserve(points_.size()); - render_points_.clear(); - if (draw_individual_points) { - for (const auto &p : points_) { - render_points_.emplace_back(p.x() * xscale, 1.0 + (max_val - p.y()) * yscale); - } - } else if (is_flat_line) { - double y = size.height() / 2.0; - render_points_.emplace_back(0.0, y); - render_points_.emplace_back(points_.back().x() * xscale, y); - } else { - double prev_y = points_.front().y(); - render_points_.emplace_back(points_.front().x() * xscale, 1.0 + (max_val - prev_y) * yscale); - bool in_flat = false; - - for (size_t i = 1; i < points_.size(); ++i) { - const auto &p = points_[i]; - double y = p.y(); - if (std::abs(y - prev_y) < 1e-6) { - in_flat = true; - } else { - if (in_flat) render_points_.emplace_back(points_[i - 1].x() * xscale, 1.0 + (max_val - prev_y) * yscale); - render_points_.emplace_back(p.x() * xscale, 1.0 + (max_val - y) * yscale); - in_flat = false; - } - prev_y = y; - } - if (in_flat) render_points_.emplace_back(points_.back().x() * xscale, 1.0 + (max_val - prev_y) * yscale); - } - - // Render to pixmap - qreal dpr = qApp->devicePixelRatio(); - const QSize pixmap_size = size * dpr; - if (pixmap.size() != pixmap_size) { - pixmap = QPixmap(pixmap_size); - } - pixmap.setDevicePixelRatio(dpr); - pixmap.fill(Qt::transparent); - QPainter painter(&pixmap); - painter.setRenderHint(QPainter::Antialiasing, render_points_.size() <= 500); - painter.setPen(color); - painter.drawPolyline(render_points_.data(), render_points_.size()); - - painter.setPen(QPen(color, 3)); - if (draw_individual_points) { - painter.drawPoints(render_points_.data(), render_points_.size()); - } else { - painter.drawPoint(render_points_.back()); - } -} diff --git a/openpilot/tools/cabana/chart/sparkline.h b/openpilot/tools/cabana/chart/sparkline.h deleted file mode 100644 index 7f30047d0d..0000000000 --- a/openpilot/tools/cabana/chart/sparkline.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "tools/cabana/dbc/dbc.h" -#include "tools/cabana/streams/abstractstream.h" - -class Sparkline { -public: - void update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, QSize size); - inline double freq() const { return freq_; } - bool isEmpty() const { return pixmap.isNull(); } - - QPixmap pixmap; - double min_val = 0; - double max_val = 0; - -private: - void render(const QColor &color, int range, QSize size); - - std::vector points_; - std::vector render_points_; - double freq_ = 0; -}; diff --git a/openpilot/tools/cabana/chart/tiplabel.cc b/openpilot/tools/cabana/chart/tiplabel.cc deleted file mode 100644 index c250ebf877..0000000000 --- a/openpilot/tools/cabana/chart/tiplabel.cc +++ /dev/null @@ -1,58 +0,0 @@ -#include "tools/cabana/chart/tiplabel.h" - -#include - -#include -#include -#include - -#include "tools/cabana/settings.h" -#include "tools/cabana/utils/qtutil.h" - -TipLabel::TipLabel(QWidget *parent) : QLabel(parent, Qt::ToolTip | Qt::FramelessWindowHint) { - setAttribute(Qt::WA_ShowWithoutActivating); - setAttribute(Qt::WA_TransparentForMouseEvents); - - setForegroundRole(QPalette::ToolTipText); - setBackgroundRole(QPalette::ToolTipBase); - - QFont font; - font.setPointSizeF(8.34563465); - setFont(font); - auto palette = QToolTip::palette(); - if (!utils::isDarkTheme()) { - palette.setColor(QPalette::ToolTipBase, QApplication::palette().color(QPalette::Base)); - palette.setColor(QPalette::ToolTipText, QRgb(0x404044)); // same color as chart label brush - } - setPalette(palette); - ensurePolished(); - setMargin(1 + style()->pixelMetric(QStyle::PM_ToolTipLabelFrameWidth, nullptr, this)); - setTextFormat(Qt::RichText); -} - -void TipLabel::showText(const QPoint &pt, const QString &text, QWidget *w, const QRect &rect) { - setText(text); - if (!text.isEmpty()) { - QSize extra(1, 1); - resize(sizeHint() + extra); - QPoint tip_pos(pt.x() + 8, rect.top() + 2); - if (tip_pos.x() + size().width() >= rect.right()) { - tip_pos.rx() = pt.x() - size().width() - 8; - } - if (rect.contains({tip_pos, size()})) { - move(w->mapToGlobal(tip_pos)); - setVisible(true); - return; - } - } - setVisible(false); -} - -void TipLabel::paintEvent(QPaintEvent *ev) { - QStylePainter p(this); - QStyleOptionFrame opt; - opt.init(this); - p.drawPrimitive(QStyle::PE_PanelTipLabel, opt); - p.end(); - QLabel::paintEvent(ev); -} diff --git a/openpilot/tools/cabana/chart/tiplabel.h b/openpilot/tools/cabana/chart/tiplabel.h deleted file mode 100644 index cc96aa9864..0000000000 --- a/openpilot/tools/cabana/chart/tiplabel.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include - -class TipLabel : public QLabel { - Q_OBJECT - -public: - TipLabel(QWidget *parent = nullptr); - void showText(const QPoint &pt, const QString &sec, QWidget *w, const QRect &rect); - void paintEvent(QPaintEvent *ev) override; -}; diff --git a/openpilot/tools/cabana/detailwidget.cc b/openpilot/tools/cabana/detailwidget.cc deleted file mode 100644 index d0cccb5992..0000000000 --- a/openpilot/tools/cabana/detailwidget.cc +++ /dev/null @@ -1,323 +0,0 @@ -#include "tools/cabana/detailwidget.h" - -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/mainwin.h" - -// DetailWidget - -DetailWidget::DetailWidget(ChartsWidget *charts, QWidget *parent) : charts(charts), QWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - - // tabbar - tabbar = new TabBar(this); - tabbar->setUsesScrollButtons(true); - tabbar->setAutoHide(true); - tabbar->setContextMenuPolicy(Qt::CustomContextMenu); - main_layout->addWidget(tabbar); - - createToolBar(); - - // warning - warning_widget = new QWidget(this); - QHBoxLayout *warning_hlayout = new QHBoxLayout(warning_widget); - warning_hlayout->addWidget(warning_icon = new QLabel(this), 0, Qt::AlignTop); - warning_hlayout->addWidget(warning_label = new QLabel(this), 1, Qt::AlignLeft); - warning_widget->hide(); - main_layout->addWidget(warning_widget); - - // msg widget - splitter = new QSplitter(Qt::Vertical, this); - splitter->addWidget(binary_view = new BinaryView(this)); - splitter->addWidget(signal_view = new SignalView(charts, this)); - binary_view->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum); - signal_view->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); - splitter->setStretchFactor(0, 0); - splitter->setStretchFactor(1, 1); - - tab_widget = new QTabWidget(this); - tab_widget->setStyleSheet("QTabWidget::pane {border: none; margin-bottom: -2px;}"); - tab_widget->setTabPosition(QTabWidget::South); - tab_widget->addTab(splitter, utils::icon("file-earmark-ruled"), "&Msg"); - tab_widget->addTab(history_log = new LogsWidget(this), utils::icon("stopwatch"), "&Logs"); - main_layout->addWidget(tab_widget); - - QObject::connect(binary_view, &BinaryView::signalHovered, signal_view, &SignalView::signalHovered); - QObject::connect(binary_view, &BinaryView::signalClicked, [this](const cabana::Signal *s) { signal_view->selectSignal(s, true); }); - QObject::connect(binary_view, &BinaryView::editSignal, signal_view->model, &SignalModel::saveSignal); - QObject::connect(binary_view, &BinaryView::showChart, charts, &ChartsWidget::showChart); - QObject::connect(signal_view, &SignalView::showChart, charts, &ChartsWidget::showChart); - QObject::connect(signal_view, &SignalView::highlight, binary_view, &BinaryView::highlight); - QObject::connect(tab_widget, &QTabWidget::currentChanged, [this]() { updateState(); }); - connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); - connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); - connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); - QObject::connect(tabbar, &QTabBar::customContextMenuRequested, this, &DetailWidget::showTabBarContextMenu); - QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) { - if (index != -1) { - setMessage(tabbar->tabData(index).value()); - } - }); - QObject::connect(tabbar, &QTabBar::tabCloseRequested, tabbar, &QTabBar::removeTab); - QObject::connect(charts, &ChartsWidget::seriesChanged, signal_view, &SignalView::updateChartState); -} - -void DetailWidget::createToolBar() { - QToolBar *toolbar = new QToolBar(this); - int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize); - toolbar->setIconSize({icon_size, icon_size}); - toolbar->addWidget(name_label = new ElidedLabel(this)); - name_label->setStyleSheet("QLabel{font-weight:bold;}"); - - QWidget *spacer = new QWidget(); - spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - toolbar->addWidget(spacer); - -// Heatmap label and radio buttons - toolbar->addWidget(new QLabel(tr("Heatmap:"), this)); - auto *heatmap_live = new QRadioButton(tr("Live"), this); - auto *heatmap_all = new QRadioButton(tr("All"), this); - heatmap_live->setChecked(true); - - toolbar->addWidget(heatmap_live); - toolbar->addWidget(heatmap_all); - - // Edit and remove buttons - toolbar->addSeparator(); - toolbar->addAction(utils::icon("pencil"), tr("Edit Message"), this, &DetailWidget::editMsg); - action_remove_msg = toolbar->addAction(utils::icon("x-lg"), tr("Remove Message"), this, &DetailWidget::removeMsg); - - layout()->addWidget(toolbar); - - connect(heatmap_live, &QAbstractButton::toggled, this, [this](bool on) { binary_view->setHeatmapLiveMode(on); }); - connections_.push_back(can->timeRangeChanged.connect([=](const std::optional> &range) { - auto text = range ? QString("%1 - %2").arg(range->first, 0, 'f', 3).arg(range->second, 0, 'f', 3) : "All"; - heatmap_all->setText(text); - (range ? heatmap_all : heatmap_live)->setChecked(true); - })); -} - -void DetailWidget::showTabBarContextMenu(const QPoint &pt) { - int index = tabbar->tabAt(pt); - if (index >= 0) { - QMenu menu(this); - menu.addAction(tr("Close Other Tabs")); - if (menu.exec(tabbar->mapToGlobal(pt))) { - tabbar->moveTab(index, 0); - tabbar->setCurrentIndex(0); - while (tabbar->count() > 1) { - tabbar->removeTab(1); - } - } - } -} - -int DetailWidget::findOrAddTab(const MessageId& message_id) { - int index = tabbar->count() - 1; - for (/**/; index >= 0; --index) { - if (tabbar->tabData(index).value() == message_id) break; - } - if (index == -1) { - index = tabbar->addTab(QString::fromStdString(message_id.toString())); - tabbar->setTabData(index, QVariant::fromValue(message_id)); - tabbar->setTabToolTip(index, QString::fromStdString(msgName(message_id))); - } - return index; -} - -void DetailWidget::setMessage(const MessageId &message_id) { - if (std::exchange(msg_id, message_id) == message_id) return; - - tabbar->blockSignals(true); - int index = findOrAddTab(message_id); - tabbar->setCurrentIndex(index); - tabbar->blockSignals(false); - - setUpdatesEnabled(false); - signal_view->setMessage(msg_id); - binary_view->setMessage(msg_id); - history_log->setMessage(msg_id); - refresh(); - setUpdatesEnabled(true); -} - -std::pair DetailWidget::serializeMessageIds() const { - QStringList msgs; - for (int i = 0; i < tabbar->count(); ++i) { - MessageId id = tabbar->tabData(i).value(); - msgs.append(QString::fromStdString(id.toString())); - } - 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.toStdString()); - if (dbc()->msg(id) != nullptr) - findOrAddTab(id); - } - tabbar->blockSignals(false); - - auto active_id = MessageId::fromString(active_msg_id.toStdString()); - if (dbc()->msg(active_id) != nullptr) - setMessage(active_id); -} - -void DetailWidget::refresh() { - QStringList warnings; - auto msg = dbc()->msg(msg_id); - if (msg) { - if (msg_id.source == INVALID_SOURCE) { - warnings.push_back(tr("No messages received.")); - } else if (msg->size != can->lastMessage(msg_id).dat.size()) { - 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(QString::fromStdString(s->name))); - } - } - 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); - - if (!warnings.isEmpty()) { - warning_label->setText(warnings.join('\n')); - warning_icon->setPixmap(utils::icon(msg ? "exclamation-triangle" : "info-circle")); - } - warning_widget->setVisible(!warnings.isEmpty()); -} - -void DetailWidget::updateState(const std::set *msgs) { - if ((msgs && !msgs->count(msg_id))) - return; - - if (tab_widget->currentIndex() == 0) - binary_view->updateState(); - else - history_log->updateState(); -} - -void DetailWidget::editMsg() { - auto msg = dbc()->msg(msg_id); - int size = msg ? msg->size : can->lastMessage(msg_id).dat.size(); - EditMessageDialog dlg(msg_id, QString::fromStdString(msgName(msg_id)), size, this); - if (dlg.exec()) { - 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::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(QString::fromStdString(msg_id.toString()))); - QFormLayout *form_layout = new QFormLayout(this); - - form_layout->addRow("", error_label = new QLabel); - error_label->setVisible(false); - form_layout->addRow(tr("Name"), name_edit = new QLineEdit(title, this)); - name_edit->setValidator(new NameValidator(name_edit)); - - form_layout->addRow(tr("Size"), size_spin = new QSpinBox(this)); - size_spin->setRange(1, CAN_MAX_DATA_BYTES); - size_spin->setValue(size); - - form_layout->addRow(tr("Node"), node = new QLineEdit(this)); - node->setValidator(new NameValidator(name_edit)); - form_layout->addRow(tr("Comment"), comment_edit = new QTextEdit(this)); - form_layout->addRow(btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel)); - - if (auto msg = dbc()->msg(msg_id)) { - node->setText(QString::fromStdString(msg->transmitter)); - comment_edit->setText(QString::fromStdString(msg->comment)); - } - validateName(name_edit->text()); - setFixedWidth(parent->width() * 0.9); - connect(name_edit, &QLineEdit::textEdited, this, &EditMessageDialog::validateName); - connect(btn_box, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject); -} - -void EditMessageDialog::validateName(const QString &text) { - 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.toStdString()) == nullptr; - if (!valid) { - error_label->setText(tr("Name already exists")); - error_label->setVisible(true); - } - } - btn_box->button(QDialogButtonBox::Ok)->setEnabled(valid); -} - -// CenterWidget - -CenterWidget::CenterWidget(QWidget *parent) : QWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->addWidget(welcome_widget = createWelcomeWidget()); -} - -DetailWidget* CenterWidget::ensureDetailWidget() { - if (!detail_widget) { - delete welcome_widget; - welcome_widget = nullptr; - layout()->addWidget(detail_widget = new DetailWidget(((MainWindow*)parentWidget())->charts_widget, this)); - } - return detail_widget; -} - -void CenterWidget::clear() { - delete detail_widget; - detail_widget = nullptr; - if (!welcome_widget) { - layout()->addWidget(welcome_widget = createWelcomeWidget()); - } -} - -QWidget *CenterWidget::createWelcomeWidget() { - QWidget *w = new QWidget(this); - QVBoxLayout *main_layout = new QVBoxLayout(w); - main_layout->addStretch(0); - QLabel *logo = new QLabel("CABANA"); - logo->setAlignment(Qt::AlignCenter); - logo->setStyleSheet("font-size:50px;font-weight:bold;"); - main_layout->addWidget(logo); - - auto newShortcutRow = [](const QString &title, const QString &key) { - QHBoxLayout *hlayout = new QHBoxLayout(); - auto btn = new QToolButton(); - btn->setText(key); - btn->setEnabled(false); - hlayout->addWidget(new QLabel(title), 0, Qt::AlignRight); - hlayout->addWidget(btn, 0, Qt::AlignLeft); - return hlayout; - }; - - auto lb = new QLabel(tr("<-Select a message to view details")); - lb->setAlignment(Qt::AlignHCenter); - main_layout->addWidget(lb); - main_layout->addLayout(newShortcutRow("Pause", "Space")); - main_layout->addLayout(newShortcutRow("Help", "F1")); - main_layout->addLayout(newShortcutRow("WhatsThis", "Shift+F1")); - main_layout->addStretch(0); - - w->setStyleSheet("QLabel{color:darkGray;}"); - w->setBackgroundRole(QPalette::Base); - w->setAutoFillBackground(true); - return w; -} diff --git a/openpilot/tools/cabana/detailwidget.h b/openpilot/tools/cabana/detailwidget.h deleted file mode 100644 index c003548da4..0000000000 --- a/openpilot/tools/cabana/detailwidget.h +++ /dev/null @@ -1,78 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/binaryview.h" -#include "tools/cabana/chart/chartswidget.h" -#include "tools/cabana/historylog.h" -#include "tools/cabana/signalview.h" -#include "tools/cabana/utils/elidedlabel.h" -#include "tools/cabana/utils/qtutil.h" - -class EditMessageDialog : public QDialog { -public: - EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent); - void validateName(const QString &text); - - MessageId msg_id; - QString original_name; - QDialogButtonBox *btn_box; - QLineEdit *name_edit; - QLineEdit *node; - QTextEdit *comment_edit; - QLabel *error_label; - QSpinBox *size_spin; -}; - -class DetailWidget : public QWidget { - Q_OBJECT - -public: - DetailWidget(ChartsWidget *charts, QWidget *parent); - void setMessage(const MessageId &message_id); - void refresh(); - std::pair serializeMessageIds() const; - void restoreTabs(const QString active_msg_id, const QStringList &msg_ids); - -private: - void createToolBar(); - int findOrAddTab(const MessageId& message_id); - void showTabBarContextMenu(const QPoint &pt); - void editMsg(); - void removeMsg(); - void updateState(const std::set *msgs = nullptr); - - MessageId msg_id; - QLabel *warning_icon, *warning_label; - ElidedLabel *name_label; - QWidget *warning_widget; - TabBar *tabbar; - QTabWidget *tab_widget; - QAction *action_remove_msg; - LogsWidget *history_log; - BinaryView *binary_view; - SignalView *signal_view; - ChartsWidget *charts; - QSplitter *splitter; - Connections connections_; -}; - -class CenterWidget : public QWidget { - Q_OBJECT -public: - CenterWidget(QWidget *parent); - void setMessage(const MessageId &message_id) { ensureDetailWidget()->setMessage(message_id); } - DetailWidget* getDetailWidget() { return detail_widget; } - DetailWidget* ensureDetailWidget(); - void clear(); - -private: - QWidget *createWelcomeWidget(); - DetailWidget *detail_widget = nullptr; - QWidget *welcome_widget = nullptr; -}; diff --git a/openpilot/tools/cabana/historylog.cc b/openpilot/tools/cabana/historylog.cc deleted file mode 100644 index 26c0f4168f..0000000000 --- a/openpilot/tools/cabana/historylog.cc +++ /dev/null @@ -1,251 +0,0 @@ -#include "tools/cabana/historylog.h" - -#include - -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/utils/export.h" - -HistoryLogModel::HistoryLogModel(QObject *parent) : QAbstractTableModel(parent) { - connections_.push_back(can->seekedTo.connect([this](double) { reset(); })); - connections_.push_back(dbc()->fileChanged.connect([this]() { reset(); })); - connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { reset(); })); -} - -QVariant HistoryLogModel::data(const QModelIndex &index, int role) const { - const auto &m = messages[index.row()]; - 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 QString::fromStdString(sigs[col - 1]->formatValue(m.sig_values[col - 1], false)); - } else if (role == Qt::TextAlignmentRole) { - return (uint32_t)(Qt::AlignRight | Qt::AlignVCenter); - } - - if (isHexMode() && col == 1) { - if (role == ColorsRole) return QVariant::fromValue((void *)(&m.colors)); - if (role == BytesRole) return QVariant::fromValue((void *)(&m.data)); - } - return {}; -} - -void HistoryLogModel::setMessage(const MessageId &message_id) { - msg_id = message_id; - reset(); -} - -void HistoryLogModel::reset() { - beginResetModel(); - sigs.clear(); - if (auto dbc_msg = dbc()->msg(msg_id)) { - sigs = dbc_msg->getSignals(); - } - messages.clear(); - hex_colors = {}; - endResetModel(); - setFilter(0, "", nullptr); -} - -QVariant HistoryLogModel::headerData(int section, Qt::Orientation orientation, int role) const { - if (orientation == Qt::Horizontal) { - if (role == Qt::DisplayRole || role == Qt::ToolTipRole) { - if (section == 0) return "Time"; - if (isHexMode()) return "Data"; - - 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 = toQColor(sigs[section - 1]->color); - sigColor.setAlpha(128); - return QBrush(sigColor); - } - } - return {}; -} - -void HistoryLogModel::setHexMode(bool hex) { - hex_mode = hex; - reset(); -} - -void HistoryLogModel::setFilter(int sig_idx, const QString &value, std::function cmp) { - filter_sig_idx = sig_idx; - filter_value = value.toDouble(); - filter_cmp = value.isEmpty() ? nullptr : cmp; - updateState(true); -} - -void HistoryLogModel::updateState(bool clear) { - if (clear && !messages.empty()) { - beginRemoveRows({}, 0, messages.size() - 1); - messages.clear(); - endRemoveRows(); - } - uint64_t current_time = can->toMonoTime(can->lastMessage(msg_id).ts) + 1; - fetchData(messages.begin(), current_time, messages.empty() ? 0 : messages.front().mono_time); -} - -bool HistoryLogModel::canFetchMore(const QModelIndex &parent) const { - const auto &events = can->events(msg_id); - return !events.empty() && !messages.empty() && messages.back().mono_time > events.front()->mono_time; -} - -void HistoryLogModel::fetchMore(const QModelIndex &parent) { - if (!messages.empty()) - fetchData(messages.end(), messages.back().mono_time, 0); -} - -void HistoryLogModel::fetchData(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time) { - const auto &events = can->events(msg_id); - auto first = std::upper_bound(events.rbegin(), events.rend(), from_time, [](uint64_t ts, auto e) { - return ts > e->mono_time; - }); - - std::vector msgs; - std::vector values(sigs.size()); - msgs.reserve(batch_size); - for (; first != events.rend() && (*first)->mono_time > min_time; ++first) { - const CanEvent *e = *first; - for (int i = 0; i < sigs.size(); ++i) { - sigs[i]->getValue(e->dat, e->size, &values[i]); - } - if (!filter_cmp || filter_cmp(values[filter_sig_idx], filter_value)) { - msgs.emplace_back(Message{e->mono_time, values, {e->dat, e->dat + e->size}}); - if (msgs.size() >= batch_size && min_time == 0) { - break; - } - } - } - - if (!msgs.empty()) { - if (isHexMode() && (min_time > 0 || messages.empty())) { - const auto freq = can->lastMessage(msg_id).freq; - const std::vector no_mask; - for (auto &m : msgs) { - hex_colors.compute(msg_id, m.data.data(), m.data.size(), m.mono_time / (double)1e9, can->getSpeed(), no_mask, freq); - m.colors = hex_colors.colors; - } - } - int pos = std::distance(messages.begin(), insert_pos); - beginInsertRows({}, pos , pos + msgs.size() - 1); - messages.insert(insert_pos, std::move_iterator(msgs.begin()), std::move_iterator(msgs.end())); - endInsertRows(); - } -} - -// HeaderView - -QSize HeaderView::sectionSizeFromContents(int logicalIndex) const { - static const QSize time_col_size = fontMetrics().size(Qt::TextSingleLine, "000000.000") + QSize(10, 6); - if (logicalIndex == 0) { - return time_col_size; - } else { - int default_size = qMax(100, (rect().width() - time_col_size.width()) / (model()->columnCount() - 1)); - QString text = model()->headerData(logicalIndex, this->orientation(), Qt::DisplayRole).toString(); - const QRect rect = fontMetrics().boundingRect({0, 0, default_size, 2000}, defaultAlignment(), text.replace(QChar('_'), ' ')); - QSize size = rect.size() + QSize{10, 6}; - return QSize{qMax(size.width(), default_size), size.height()}; - } -} - -void HeaderView::paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const { - auto bg_role = model()->headerData(logicalIndex, Qt::Horizontal, Qt::BackgroundRole); - if (bg_role.isValid()) { - painter->fillRect(rect, bg_role.value()); - } - QString text = model()->headerData(logicalIndex, Qt::Horizontal, Qt::DisplayRole).toString(); - painter->setPen(palette().color(utils::isDarkTheme() ? QPalette::BrightText : QPalette::Text)); - painter->drawText(rect.adjusted(5, 3, -5, -3), defaultAlignment(), text.replace(QChar('_'), ' ')); -} - -// LogsWidget - -LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) { - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - - QWidget *toolbar = new QWidget(this); - toolbar->setAutoFillBackground(true); - QHBoxLayout *h = new QHBoxLayout(toolbar); - - filters_widget = new QWidget(this); - QHBoxLayout *filter_layout = new QHBoxLayout(filters_widget); - filter_layout->setContentsMargins(0, 0, 0, 0); - filter_layout->addWidget(display_type_cb = new QComboBox(this)); - filter_layout->addWidget(signals_cb = new QComboBox(this)); - filter_layout->addWidget(comp_box = new QComboBox(this)); - filter_layout->addWidget(value_edit = new QLineEdit(this)); - h->addWidget(filters_widget); - h->addStretch(0); - export_btn = new ToolButton("filetype-csv", tr("Export to CSV file...")); - h->addWidget(export_btn, 0, Qt::AlignRight); - - display_type_cb->addItems({"Signal", "Hex"}); - display_type_cb->setToolTip(tr("Display signal value or raw hex value")); - comp_box->addItems({">", "=", "!=", "<"}); - value_edit->setClearButtonEnabled(true); - value_edit->setValidator(new DoubleValidator(this)); - - main_layout->addWidget(toolbar); - QFrame *line = new QFrame(this); - line->setFrameStyle(QFrame::HLine | QFrame::Sunken); - main_layout->addWidget(line); - main_layout->addWidget(logs = new QTableView(this)); - logs->setModel(model = new HistoryLogModel(this)); - logs->setItemDelegate(delegate = new MessageBytesDelegate(this)); - logs->setHorizontalHeader(new HeaderView(Qt::Horizontal, this)); - logs->horizontalHeader()->setDefaultAlignment(Qt::AlignRight | (Qt::Alignment)Qt::TextWordWrap); - logs->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); - logs->verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); - logs->verticalHeader()->setDefaultSectionSize(delegate->sizeForBytes(8).height()); - logs->setFrameShape(QFrame::NoFrame); - - QObject::connect(display_type_cb, qOverload(&QComboBox::activated), model, &HistoryLogModel::setHexMode); - QObject::connect(signals_cb, SIGNAL(activated(int)), this, SLOT(filterChanged())); - QObject::connect(comp_box, SIGNAL(activated(int)), this, SLOT(filterChanged())); - QObject::connect(value_edit, &QLineEdit::textEdited, this, &LogsWidget::filterChanged); - QObject::connect(export_btn, &QToolButton::clicked, this, &LogsWidget::exportToCSV); - QObject::connect(model, &HistoryLogModel::modelReset, this, &LogsWidget::modelReset); - QObject::connect(model, &HistoryLogModel::rowsInserted, [this]() { export_btn->setEnabled(true); }); -} - -void LogsWidget::modelReset() { - signals_cb->clear(); - for (auto s : model->sigs) { - signals_cb->addItem(QString::fromStdString(s->name)); - } - export_btn->setEnabled(false); - value_edit->clear(); - comp_box->setCurrentIndex(0); - filters_widget->setVisible(!model->sigs.empty()); -} - -void LogsWidget::filterChanged() { - if (value_edit->text().isEmpty() && !value_edit->isModified()) return; - - std::function cmp = nullptr; - switch (comp_box->currentIndex()) { - case 0: cmp = std::greater{}; break; - case 1: cmp = std::equal_to{}; break; - case 2: cmp = [](double l, double r) { return l != r; }; break; // not equal - case 3: cmp = std::less{}; break; - } - model->setFilter(signals_cb->currentIndex(), value_edit->text(), cmp); -} - -void LogsWidget::exportToCSV() { - 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.toStdString(), model->msg_id) - : utils::exportSignalsToCSV(fn.toStdString(), model->msg_id); - } -} diff --git a/openpilot/tools/cabana/historylog.h b/openpilot/tools/cabana/historylog.h deleted file mode 100644 index 1b75e3b8a9..0000000000 --- a/openpilot/tools/cabana/historylog.h +++ /dev/null @@ -1,83 +0,0 @@ -#pragma once - -#include -#include - -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/utils/qtutil.h" - -class HeaderView : public QHeaderView { -public: - HeaderView(Qt::Orientation orientation, QWidget *parent = nullptr) : QHeaderView(orientation, parent) {} - QSize sectionSizeFromContents(int logicalIndex) const override; - void paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const; -}; - -class HistoryLogModel : public QAbstractTableModel { - Q_OBJECT - -public: - HistoryLogModel(QObject *parent); - void setMessage(const MessageId &message_id); - void updateState(bool clear = false); - void setFilter(int sig_idx, const QString &value, std::function cmp); - QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - void fetchMore(const QModelIndex &parent) override; - bool canFetchMore(const QModelIndex &parent) const override; - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return messages.size(); } - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return !isHexMode() ? sigs.size() + 1 : 2; } - inline bool isHexMode() const { return sigs.empty() || hex_mode; } - void reset(); - void setHexMode(bool hex_mode); - - struct Message { - uint64_t mono_time = 0; - std::vector sig_values; - std::vector data; - std::vector colors; - }; - - void fetchData(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time); - - MessageId msg_id; - CanData hex_colors; - const int batch_size = 50; - int filter_sig_idx = -1; - double filter_value = 0; - std::function filter_cmp = nullptr; - std::deque messages; - std::vector sigs; - bool hex_mode = false; - Connections connections_; -}; - -class LogsWidget : public QFrame { - Q_OBJECT - -public: - LogsWidget(QWidget *parent); - void setMessage(const MessageId &message_id) { model->setMessage(message_id); } - void updateState() { model->updateState(); } - void showEvent(QShowEvent *event) override { model->updateState(true); } - -private slots: - void filterChanged(); - void exportToCSV(); - void modelReset(); - -private: - QTableView *logs; - HistoryLogModel *model; - QComboBox *signals_cb, *comp_box, *display_type_cb; - QLineEdit *value_edit; - QWidget *filters_widget; - ToolButton *export_btn; - MessageBytesDelegate *delegate; -}; diff --git a/openpilot/tools/cabana/mainwin.cc b/openpilot/tools/cabana/mainwin.cc deleted file mode 100644 index c7a838a756..0000000000 --- a/openpilot/tools/cabana/mainwin.cc +++ /dev/null @@ -1,752 +0,0 @@ -#include "tools/cabana/mainwin.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "json11/json11.hpp" -#include "tools/cabana/commands.h" -#include "tools/cabana/settingsdialog.h" -#include "tools/cabana/streamselector.h" -#include "tools/cabana/tools/findsignal.h" -#include "tools/cabana/utils/export.h" -#include "tools/cabana/utils/qtutil.h" -#include "tools/replay/py_downloader.h" -#include "tools/replay/util.h" - -MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainWindow() { - loadFingerprints(); - createDockWindows(); - setCentralWidget(center_widget = new CenterWidget(this)); - createActions(); - createStatusBar(); - createShortcuts(); - - // save default window state to allow resetting it - default_state = utils::toBytes(saveState()); - - // restore states; restoreGeometry() itself corrects stale off-screen geometry - restoreGeometry(utils::qbytes(settings.geometry)); - restoreState(utils::qbytes(settings.window_state)); - - // download handlers are called from download threads - static auto static_main_win = this; - installDownloadProgressHandler([](uint64_t cur, uint64_t total, bool success) { - utils::runOnMainThread([=]() { static_main_win->updateDownloadProgress(cur, total, success); }); - }); - installMessageHandler([](ReplyMsgType type, const std::string msg) { - utils::runOnMainThread([=]() { static_main_win->statusBar()->showMessage(QString::fromStdString(msg), 2000); }); - }); - - setStyleSheet(QString(R"(QMainWindow::separator { - width: %1px; /* when vertical */ - height: %1px; /* when horizontal */ - })").arg(style()->pixelMetric(QStyle::PM_SplitterWidth))); - - connections_.push_back(dbc()->fileChanged.connect([this]() { DBCFileChanged(); })); - connections_.push_back(UndoStack::instance()->cleanChanged.connect([this](bool clean) { undoStackCleanChanged(clean); })); - connections_.push_back(settings.changed.connect([this]() { updateStatus(); })); - - // temporary pump for the non-Qt main thread queue until imgui owns the loop - auto *queue_timer = new QTimer(this); - QObject::connect(queue_timer, &QTimer::timeout, utils::drainMainThreadQueue); - queue_timer->start(10); - - QTimer::singleShot(0, this, [=]() { stream ? openStream(stream, dbc_file) : selectAndOpenStream(); }); - show(); -} - -void MainWindow::loadFingerprints() { - 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()); - } - } -} - -void MainWindow::createActions() { - // File menu - QMenu *file_menu = menuBar()->addMenu(tr("&File")); - file_menu->addAction(tr("Open Stream..."), this, &MainWindow::selectAndOpenStream); - close_stream_act = file_menu->addAction(tr("Close stream"), this, &MainWindow::closeStream); - export_to_csv_act = file_menu->addAction(tr("Export to CSV..."), this, &MainWindow::exportToCSV); - close_stream_act->setEnabled(false); - export_to_csv_act->setEnabled(false); - file_menu->addSeparator(); - - file_menu->addAction(tr("New DBC File"), [this]() { newFile(); }, QKeySequence::New); - file_menu->addAction(tr("Open DBC File..."), [this]() { openFile(); }, QKeySequence::Open); - - manage_dbcs_menu = file_menu->addMenu(tr("Manage &DBC Files")); - QObject::connect(manage_dbcs_menu, &QMenu::aboutToShow, this, &MainWindow::updateLoadSaveMenus); - - open_recent_menu = file_menu->addMenu(tr("Open &Recent")); - QObject::connect(open_recent_menu, &QMenu::aboutToShow, this, &MainWindow::updateRecentFileMenu); - - file_menu->addSeparator(); - QMenu *load_opendbc_menu = file_menu->addMenu(tr("Load DBC from commaai/opendbc")); - // load_opendbc_menu->setStyleSheet("QMenu { menu-scrollable: true; }"); - 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_opendbc_menu->addAction(name, [this, name]() { loadDBCFromOpendbc(name); }); - } - - file_menu->addAction(tr("Load DBC From Clipboard"), [=]() { loadFromClipboard(); }); - - file_menu->addSeparator(); - save_dbc = file_menu->addAction(tr("Save DBC..."), this, &MainWindow::save, QKeySequence::Save); - save_dbc_as = file_menu->addAction(tr("Save DBC As..."), this, &MainWindow::saveAs, QKeySequence::SaveAs); - copy_dbc_to_clipboard = file_menu->addAction(tr("Copy DBC To Clipboard"), this, &MainWindow::saveToClipboard); - - file_menu->addSeparator(); - file_menu->addAction(tr("Settings..."), this, &MainWindow::setOption, QKeySequence::Preferences); - - file_menu->addSeparator(); - file_menu->addAction(tr("E&xit"), qApp, &QApplication::closeAllWindows, QKeySequence::Quit); - - // Edit Menu - QMenu *edit_menu = menuBar()->addMenu(tr("&Edit")); - undo_act = edit_menu->addAction(tr("&Undo"), []() { UndoStack::instance()->undo(); }); - undo_act->setShortcuts(QKeySequence::Undo); - redo_act = edit_menu->addAction(tr("&Redo"), []() { UndoStack::instance()->redo(); }); - redo_act->setShortcuts(QKeySequence::Redo); - connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { updateUndoRedoActions(); })); - updateUndoRedoActions(); - - // View Menu - QMenu *view_menu = menuBar()->addMenu(tr("&View")); - auto act = view_menu->addAction(tr("Full Screen"), this, &MainWindow::toggleFullScreen, QKeySequence::FullScreen); - addAction(act); - view_menu->addSeparator(); - view_menu->addAction(messages_dock->toggleViewAction()); - view_menu->addAction(video_dock->toggleViewAction()); - view_menu->addSeparator(); - view_menu->addAction(tr("Reset Window Layout"), [this]() { restoreState(utils::qbytes(default_state)); }); - - // Tools Menu - tools_menu = menuBar()->addMenu(tr("&Tools")); - tools_menu->addAction(tr("Find &Similar Bits"), this, &MainWindow::findSimilarBits); - tools_menu->addAction(tr("&Find Signal"), this, &MainWindow::findSignal); - - // Help Menu - QMenu *help_menu = menuBar()->addMenu(tr("&Help")); - help_menu->addAction(tr("Help"), this, &MainWindow::onlineHelp, QKeySequence::HelpContents); - help_menu->addAction(tr("About &Qt"), qApp, &QApplication::aboutQt); -} - -void MainWindow::createDockWindows() { - messages_dock = new QDockWidget(tr("MESSAGES"), this); - messages_dock->setObjectName("MessagesPanel"); - messages_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::TopDockWidgetArea | Qt::BottomDockWidgetArea); - messages_dock->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); - addDockWidget(Qt::LeftDockWidgetArea, messages_dock); - - video_dock = new QDockWidget("", this); - video_dock->setObjectName(tr("VideoPanel")); - video_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); - video_dock->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); - addDockWidget(Qt::RightDockWidgetArea, video_dock); -} - -void MainWindow::createDockWidgets() { - messages_widget = new MessagesWidget(this); - messages_dock->setWidget(messages_widget); - QObject::connect(messages_widget, &MessagesWidget::titleChanged, messages_dock, &QDockWidget::setWindowTitle); - QObject::connect(messages_widget, &MessagesWidget::msgSelectionChanged, center_widget, &CenterWidget::setMessage); - - // right panel - charts_widget = new ChartsWidget(this); - QWidget *charts_container = new QWidget(this); - charts_layout = new QVBoxLayout(charts_container); - charts_layout->setContentsMargins(0, 0, 0, 0); - charts_layout->addWidget(charts_widget); - - // splitter between video and charts - video_splitter = new QSplitter(Qt::Vertical, this); - video_widget = new VideoWidget(this); - video_splitter->addWidget(video_widget); - - video_splitter->addWidget(charts_container); - video_splitter->setStretchFactor(1, 1); - 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); - QObject::connect(charts_widget, &ChartsWidget::showTip, video_widget, &VideoWidget::showThumbnail); -} - -void MainWindow::createStatusBar() { - progress_bar = new QProgressBar(); - progress_bar->setRange(0, 100); - progress_bar->setTextVisible(true); - progress_bar->setFixedSize({300, 16}); - progress_bar->setVisible(false); - statusBar()->addWidget(new QLabel(tr("For Help, Press F1"))); - statusBar()->addPermanentWidget(progress_bar); - statusBar()->addPermanentWidget(status_label = new QLabel(this)); - updateStatus(); -} - -void MainWindow::createShortcuts() { - auto shortcut = new QShortcut(QKeySequence(Qt::Key_Space), this, nullptr, nullptr, Qt::ApplicationShortcut); - QObject::connect(shortcut, &QShortcut::activated, this, []() { - if (can) can->pause(!can->isPaused()); - }); - // TODO: add more shortcuts here. -} - -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(); - - // Update file menu - int cnt = dbc()->nonEmptyDBCCount(); - save_dbc->setText(cnt > 1 ? tr("Save %1 DBCs...").arg(cnt) : tr("Save DBC...")); - save_dbc->setEnabled(cnt > 0); - save_dbc_as->setEnabled(cnt == 1); - // TODO: Support clipboard for multiple files - copy_dbc_to_clipboard->setEnabled(cnt == 1); - manage_dbcs_menu->setEnabled(dynamic_cast(can) == nullptr); - - QStringList title; - for (auto f : dbc()->allDBCFiles()) { - title.push_back(tr("(%1) %2").arg(QString::fromStdString(toString(dbc()->sources(f))), QString::fromStdString(f->name()))); - } - setWindowFilePath(title.join(" | ")); - - QTimer::singleShot(0, this, &::MainWindow::restoreSessionState); -} - -void MainWindow::selectAndOpenStream() { - StreamSelector dlg(this); - if (dlg.exec()) { - openStream(dlg.stream(), dlg.dbcFile()); - } else if (!can) { - openStream(new DummyStream()); - } -} - -void MainWindow::closeStream() { - openStream(new DummyStream()); - if (dbc()->nonEmptyDBCCount() > 0) { - dbc()->fileChanged(); - } - statusBar()->showMessage(tr("stream closed")); -} - -void MainWindow::exportToCSV() { - 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.toStdString()); - } -} - -void MainWindow::newFile(SourceSet s) { - closeFile(s); - dbc()->open(s, std::string(""), std::string("")); -} - -void MainWindow::openFile(SourceSet s) { - remindSaveChanges(); - QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)"); - if (!fn.isEmpty()) { - loadFile(fn, s); - } -} - -void MainWindow::loadFile(const QString &fn, SourceSet s) { - if (!fn.isEmpty()) { - closeFile(s); - - 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(QString::fromStdString(error)); - msg_box.exec(); - } - } -} - -void MainWindow::loadDBCFromOpendbc(const QString &name) { - loadFile(QString("%1/%2").arg(OPENDBC_FILE_PATH, 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); - - 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(QString::fromStdString(error)); - msg_box.exec(); - } -} - -// stream threads read the global `can` until its destructor joins them -MainWindow::~MainWindow() { - delete can; - can = nullptr; -} - -void MainWindow::openStream(AbstractStream *stream, const QString &dbc_file) { - stream_connections_.clear(); - if (wait_dlg_) wait_dlg_->deleteLater(); - wait_dlg_ = nullptr; - delete can; - can = nullptr; - startStream(stream, dbc_file); -} - -void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { - center_widget->clear(); - delete messages_widget; - delete video_splitter; - - can = stream; // take ownership - stream_connections_.push_back(can->error.connect([this](const std::string &msg) { - QMessageBox::warning(this, tr("Error"), QString::fromStdString(msg)); - })); - can->start(); - - loadFile(dbc_file); - 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); - export_to_csv_act->setEnabled(has_stream); - tools_menu->setEnabled(has_stream); - createDockWidgets(); - - 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}); - } - // Don't overwrite already loaded DBC - if (!dbc()->nonEmptyDBCCount()) { - newFile(); - } - - stream_connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &) { eventsMerged(); })); - - if (has_stream) { - wait_dlg_ = new QProgressDialog( - can->liveStreaming() ? tr("Waiting for the live stream to start...") : tr("Loading segment data..."), - tr("&Abort"), 0, 100, this); - wait_dlg_->setWindowModality(Qt::WindowModal); - wait_dlg_->setFixedSize(400, wait_dlg_->sizeHint().height()); - QObject::connect(wait_dlg_, &QProgressDialog::canceled, this, &MainWindow::close); - wait_dlg_connection_ = can->eventsMerged.connect([this](const MessageEventsMap &) { - wait_dlg_->deleteLater(); - wait_dlg_ = nullptr; - wait_dlg_connection_.disconnect(); - }); - } -} - -void MainWindow::eventsMerged() { - if (!can->liveStreaming() && std::exchange(car_fingerprint, QString::fromStdString(can->carFingerprint())) != car_fingerprint) { - video_dock->setWindowTitle(tr("ROUTE: %1 FINGERPRINT: %2") - .arg(QString::fromStdString(can->routeName())) - .arg(car_fingerprint.isEmpty() ? tr("Unknown Car") : car_fingerprint)); - // Don't overwrite already loaded 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"); - }); - } - } -} - -void MainWindow::save() { - // Save all open DBC files - for (auto dbc_file : dbc()->allDBCFiles()) { - if (dbc_file->isEmpty()) continue; - saveFile(dbc_file); - } -} - -void MainWindow::saveAs() { - // Save as all open DBC files. Should not be called with more than 1 file open - for (auto dbc_file : dbc()->allDBCFiles()) { - if (dbc_file->isEmpty()) continue; - saveFileAs(dbc_file); - } -} - -void MainWindow::closeFile(SourceSet s) { - remindSaveChanges(); - if (s == SOURCE_ALL) { - dbc()->closeAll(); - } else { - dbc()->close(s); - } -} - -void MainWindow::closeFile(DBCFile *dbc_file) { - assert(dbc_file != nullptr); - remindSaveChanges(); - dbc()->close(dbc_file); - // Ensure we always have at least one file open - if (dbc()->dbcCount() == 0) { - newFile(); - } -} - -void MainWindow::saveFile(DBCFile *dbc_file) { - assert(dbc_file != nullptr); - if (!dbc_file->filename.empty()) { - dbc_file->save(); - UndoStack::instance()->setClean(); - statusBar()->showMessage(tr("File saved"), 2000); - } else if (!dbc_file->isEmpty()) { - saveFileAs(dbc_file); - } -} - -void MainWindow::saveFileAs(DBCFile *dbc_file) { - 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.toStdString()); - UndoStack::instance()->setClean(); - statusBar()->showMessage(tr("File saved as %1").arg(fn), 2000); - updateRecentFiles(fn); - } -} - -void MainWindow::saveToClipboard() { - // Copy all open DBC files to clipboard. Should not be called with more than 1 file open - for (auto dbc_file : dbc()->allDBCFiles()) { - if (dbc_file->isEmpty()) continue; - saveFileToClipboard(dbc_file); - } -} - -void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { - assert(dbc_file != nullptr); - 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() { - manage_dbcs_menu->clear(); - - for (int source : can->sources) { - if (source >= 64) continue; // Sent and blocked buses are handled implicitly - - SourceSet ss = {source, uint8_t(source + 128), uint8_t(source + 192)}; - - QMenu *bus_menu = new QMenu(this); - bus_menu->addAction(tr("New DBC File..."), [=]() { newFile(ss); }); - bus_menu->addAction(tr("Open DBC File..."), [=]() { openFile(ss); }); - bus_menu->addAction(tr("Load DBC From Clipboard..."), [=]() { loadFromClipboard(ss, false); }); - - // Show sub-menu for each dbc for this source. - auto dbc_file = dbc()->findDBCFile(source); - if (dbc_file) { - bus_menu->addSeparator(); - 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 ? QString::fromStdString(dbc_file->name()) : "No DBCs loaded")); - - manage_dbcs_menu->addMenu(bus_menu); - } -} - -void MainWindow::updateRecentFiles(const QString &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.pop_back(); - } - settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string(); -} - -void MainWindow::updateRecentFileMenu() { - open_recent_menu->clear(); - - int num_recent_files = std::min(settings.recent_files.size(), MAX_RECENT_FILES); - if (!num_recent_files) { - open_recent_menu->addAction(tr("No Recent Files"))->setEnabled(false); - return; - } - - for (int i = 0; i < num_recent_files; ++i) { - 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)); }); - } -} - -void MainWindow::remindSaveChanges() { - while (!UndoStack::instance()->isClean()) { - QString text = tr("You have unsaved changes. Press ok to save them, cancel to discard."); - int ret = QMessageBox::question(this, tr("Unsaved Changes"), text, QMessageBox::Ok | QMessageBox::Cancel); - if (ret != QMessageBox::Ok) break; - save(); - } - UndoStack::instance()->clear(); -} - -void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool success) { - if (wait_dlg_) wait_dlg_->setValue((int)((cur / (double)total) * 100)); - if (success && cur < total) { - progress_bar->setValue((cur / (double)total) * 100); - progress_bar->setFormat(tr("Downloading %p% (%1)").arg(formattedDataSize(total).c_str())); - progress_bar->show(); - } else { - progress_bar->hide(); - } -} - -void MainWindow::updateStatus() { - status_label->setText(tr("Cached Minutes:%1").arg(settings.max_cached_minutes)); -} - -bool MainWindow::eventFilter(QObject *obj, QEvent *event) { - if (obj == floating_window && event->type() == QEvent::Close) { - toggleChartsDocking(); - return true; - } - return QMainWindow::eventFilter(obj, event); -} - -void MainWindow::toggleChartsDocking() { - if (floating_window) { - // Dock the charts widget back to the main window - floating_window->removeEventFilter(this); - charts_layout->insertWidget(0, charts_widget, 1); - floating_window->deleteLater(); - floating_window = nullptr; - charts_widget->setIsDocked(true); - } else { - // Float the charts widget in a separate window - floating_window = new QWidget(this, Qt::Window); - floating_window->setWindowTitle("Charts"); - floating_window->setLayout(new QVBoxLayout()); - floating_window->layout()->addWidget(charts_widget); - floating_window->installEventFilter(this); - floating_window->showMaximized(); - charts_widget->setIsDocked(false); - } -} - -void MainWindow::closeEvent(QCloseEvent *event) { - remindSaveChanges(); - - installDownloadProgressHandler(nullptr); - installMessageHandler(nullptr); - - if (floating_window) - floating_window->deleteLater(); - - // save states - settings.geometry = utils::toBytes(saveGeometry()); - settings.window_state = utils::toBytes(saveState()); - if (can && !can->liveStreaming()) { - 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); -} - -void MainWindow::setOption() { - SettingsDialog dlg(this); - dlg.exec(); -} - -void MainWindow::findSimilarBits() { - FindSimilarBitsDlg *dlg = new FindSimilarBitsDlg(this); - QObject::connect(dlg, &FindSimilarBitsDlg::openMessage, messages_widget, &MessagesWidget::selectMessage); - dlg->show(); -} - -void MainWindow::findSignal() { - FindSignalDlg *dlg = new FindSignalDlg(this); - QObject::connect(dlg, &FindSignalDlg::openMessage, messages_widget, &MessagesWidget::selectMessage); - dlg->show(); -} - -void MainWindow::onlineHelp() { - if (auto help = findChild()) { - help->close(); - } else { - help = new HelpOverlay(this); - help->setGeometry(rect()); - help->show(); - help->raise(); - } -} - -void MainWindow::toggleFullScreen() { - if (isFullScreen()) { - menuBar()->show(); - statusBar()->show(); - showNormal(); - showMaximized(); - } else { - menuBar()->hide(); - statusBar()->hide(); - showFullScreen(); - } -} - -void MainWindow::saveSessionState() { - settings.recent_dbc_file = ""; - settings.active_msg_id = ""; - settings.selected_msg_ids.clear(); - settings.active_charts.clear(); - - for (auto &f : dbc()->allDBCFiles()) - if (!f->isEmpty()) { settings.recent_dbc_file = f->filename; break; } - - if (auto *detail = center_widget->getDetailWidget()) { - auto [active_id, ids] = detail->serializeMessageIds(); - 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()); - } -} - -void MainWindow::restoreSessionState() { - if (settings.recent_dbc_file.empty() || dbc()->nonEmptyDBCCount() == 0) return; - - QString dbc_file; - for (auto& f : dbc()->allDBCFiles()) - if (!f->isEmpty()) { dbc_file = QString::fromStdString(f->filename); break; } - if (dbc_file.toStdString() != settings.recent_dbc_file) return; - - 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()) { - QStringList ids; - for (const auto &id : settings.active_charts) ids.push_back(QString::fromStdString(id)); - charts_widget->restoreChartsFromIds(ids); - } -} - -// HelpOverlay -HelpOverlay::HelpOverlay(MainWindow *parent) : QWidget(parent) { - setAttribute(Qt::WA_NoSystemBackground, true); - setAttribute(Qt::WA_TranslucentBackground, true); - setAttribute(Qt::WA_DeleteOnClose); - parent->installEventFilter(this); -} - -void HelpOverlay::paintEvent(QPaintEvent *event) { - QPainter painter(this); - painter.fillRect(rect(), QColor(0, 0, 0, 50)); - auto parent = parentWidget(); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); -} - -void HelpOverlay::drawHelpForWidget(QPainter &painter, QWidget *w) { - if (w && w->isVisible() && !w->whatsThis().isEmpty()) { - QPoint pt = mapFromGlobal(w->mapToGlobal(w->rect().center())); - if (rect().contains(pt)) { - QTextDocument document; - document.setHtml(w->whatsThis()); - QSize doc_size = document.size().toSize(); - QPoint topleft = {pt.x() - doc_size.width() / 2, pt.y() - doc_size.height() / 2}; - painter.translate(topleft); - painter.fillRect(QRect{{0, 0}, doc_size}, palette().toolTipBase()); - document.drawContents(&painter); - painter.translate(-topleft); - } - } -} - -bool HelpOverlay::eventFilter(QObject *obj, QEvent *event) { - if (obj == parentWidget() && event->type() == QEvent::Resize) { - QResizeEvent *resize_event = (QResizeEvent *)(event); - setGeometry(QRect{QPoint(0, 0), resize_event->size()}); - } - return false; -} - -void HelpOverlay::mouseReleaseEvent(QMouseEvent *event) { - close(); -} diff --git a/openpilot/tools/cabana/mainwin.h b/openpilot/tools/cabana/mainwin.h deleted file mode 100644 index a57232ceb2..0000000000 --- a/openpilot/tools/cabana/mainwin.h +++ /dev/null @@ -1,122 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/chart/chartswidget.h" -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/detailwidget.h" -#include "tools/cabana/messageswidget.h" -#include "tools/cabana/videowidget.h" -#include "tools/cabana/tools/findsimilarbits.h" - -class QProgressDialog; - -class MainWindow : public QMainWindow { - Q_OBJECT - -public: - MainWindow(AbstractStream *stream, const QString &dbc_file); - ~MainWindow(); - void toggleChartsDocking(); - void showStatusMessage(const QString &msg, int timeout = 0) { statusBar()->showMessage(msg, timeout); } - void loadFile(const QString &fn, SourceSet s = SOURCE_ALL); - ChartsWidget *charts_widget = nullptr; - -public slots: - void selectAndOpenStream(); - void openStream(AbstractStream *stream, const QString &dbc_file = {}); - void closeStream(); - void exportToCSV(); - - void newFile(SourceSet s = SOURCE_ALL); - void openFile(SourceSet s = SOURCE_ALL); - void loadDBCFromOpendbc(const QString &name); - void save(); - void saveAs(); - void saveToClipboard(); - -protected: - void startStream(AbstractStream *stream, QString dbc_file); - bool eventFilter(QObject *obj, QEvent *event) override; - void remindSaveChanges(); - void closeFile(SourceSet s = SOURCE_ALL); - void closeFile(DBCFile *dbc_file); - void saveFile(DBCFile *dbc_file); - void saveFileAs(DBCFile *dbc_file); - void saveFileToClipboard(DBCFile *dbc_file); - void loadFingerprints(); - void loadFromClipboard(SourceSet s = SOURCE_ALL, bool close_all = true); - void updateRecentFiles(const QString &fn); - void updateRecentFileMenu(); - void createActions(); - void createDockWindows(); - void createStatusBar(); - void createShortcuts(); - void closeEvent(QCloseEvent *event) override; - void DBCFileChanged(); - void updateDownloadProgress(uint64_t cur, uint64_t total, bool success); - void setOption(); - void findSimilarBits(); - void findSignal(); - void undoStackCleanChanged(bool clean); - void updateUndoRedoActions(); - void onlineHelp(); - void toggleFullScreen(); - void updateStatus(); - void updateLoadSaveMenus(); - void createDockWidgets(); - void eventsMerged(); - void saveSessionState(); - void restoreSessionState(); - - VideoWidget *video_widget = nullptr; - QDockWidget *video_dock; - QDockWidget *messages_dock; - MessagesWidget *messages_widget = nullptr; - CenterWidget *center_widget; - QWidget *floating_window = nullptr; - QVBoxLayout *charts_layout; - QProgressBar *progress_bar; - QLabel *status_label; - std::unordered_map fingerprint_to_dbc; - QSplitter *video_splitter = nullptr; - enum { MAX_RECENT_FILES = 15 }; - QMenu *open_recent_menu = nullptr; - QMenu *manage_dbcs_menu = nullptr; - QMenu *tools_menu = nullptr; - QAction *close_stream_act = nullptr; - QAction *export_to_csv_act = nullptr; - 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; - std::vector default_state; - Connections connections_; - Connections stream_connections_; - Connection wait_dlg_connection_; - QProgressDialog *wait_dlg_ = nullptr; -}; - -class HelpOverlay : public QWidget { - Q_OBJECT -public: - HelpOverlay(MainWindow *parent); - -protected: - void drawHelpForWidget(QPainter &painter, QWidget *w); - void paintEvent(QPaintEvent *event) override; - void mouseReleaseEvent(QMouseEvent *event) override; - bool eventFilter(QObject *obj, QEvent *event) override; -}; diff --git a/openpilot/tools/cabana/messageswidget.cc b/openpilot/tools/cabana/messageswidget.cc deleted file mode 100644 index e2d0eba079..0000000000 --- a/openpilot/tools/cabana/messageswidget.cc +++ /dev/null @@ -1,467 +0,0 @@ -#include "tools/cabana/messageswidget.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" - -MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - // toolbar - main_layout->addWidget(createToolBar()); - // message table - main_layout->addWidget(view = new MessageView(this)); - view->setItemDelegate(delegate = new MessageBytesDelegate(view, settings.multiple_lines_hex)); - view->setModel(model = new MessageListModel(this)); - view->setHeader(header = new MessageViewHeader(this)); - view->setSortingEnabled(true); - view->sortByColumn(MessageListModel::Column::NAME, Qt::AscendingOrder); - view->setAllColumnsShowFocus(true); - view->setEditTriggers(QAbstractItemView::NoEditTriggers); - view->setItemsExpandable(false); - view->setIndentation(0); - view->setRootIsDecorated(false); - - // Must be called before setting any header parameters to avoid overriding - restoreHeaderState(settings.message_header_state); - header->setSectionsMovable(true); - header->setSectionResizeMode(MessageListModel::Column::DATA, QHeaderView::Fixed); - header->setStretchLastSection(true); - header->setContextMenuPolicy(Qt::CustomContextMenu); - - // signals/slots - QObject::connect(menu, &QMenu::aboutToShow, this, &MessagesWidget::menuAboutToShow); - QObject::connect(header, &MessageViewHeader::customContextMenuRequested, this, &MessagesWidget::headerContextMenuEvent); - QObject::connect(view->horizontalScrollBar(), &QScrollBar::valueChanged, header, &MessageViewHeader::updateHeaderPositions); - QObject::connect(model, &MessageListModel::modelReset, [this]() { - if (current_msg_id) { - selectMessage(*current_msg_id); - } - view->updateBytesSectionSize(); - updateTitle(); - }); - QObject::connect(view->selectionModel(), &QItemSelectionModel::currentChanged, [=](const QModelIndex ¤t, const QModelIndex &previous) { - if (current.isValid() && current.row() < model->items_.size()) { - const auto &id = model->items_[current.row()].id; - if (!current_msg_id || id != *current_msg_id) { - current_msg_id = id; - emit msgSelectionChanged(*current_msg_id); - } - } - }); - - setWhatsThis(tr(R"( - Message View
- - Byte color
- ■ constant changing
- ■ increasing
- ■ decreasing
- Shortcuts
- Horizontal Scrolling:  shift+wheel  - )")); -} - -QWidget *MessagesWidget::createToolBar() { - QWidget *toolbar = new QWidget(this); - QHBoxLayout *layout = new QHBoxLayout(toolbar); - layout->setContentsMargins(0, 9, 0, 0); - layout->addWidget(suppress_add = new QPushButton("Suppress Highlighted")); - layout->addWidget(suppress_clear = new QPushButton()); - suppress_clear->setToolTip(tr("Clear suppressed")); - layout->addStretch(1); - QCheckBox *suppress_defined_signals = new QCheckBox(tr("Suppress Signals"), this); - suppress_defined_signals->setToolTip(tr("Suppress defined signals")); - suppress_defined_signals->setChecked(settings.suppress_defined_signals); - layout->addWidget(suppress_defined_signals); - - auto view_button = new ToolButton("three-dots", tr("View...")); - view_button->setMenu(menu); - view_button->setPopupMode(QToolButton::InstantPopup); - view_button->setStyleSheet("QToolButton::menu-indicator { image: none; }"); - layout->addWidget(view_button); - - QObject::connect(suppress_add, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted); - QObject::connect(suppress_clear, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted); - QObject::connect(suppress_defined_signals, &QCheckBox::stateChanged, this, [](int state) { can->suppressDefinedSignals(state); }); - - suppressHighlighted(); - return toolbar; -} - -void MessagesWidget::updateTitle() { - auto stats = std::accumulate( - model->items_.begin(), model->items_.end(), std::pair(), - [](const auto &pair, const auto &item) { - auto m = dbc()->msg(item.id); - return m ? std::make_pair(pair.first + 1, pair.second + m->sigs.size()) : pair; - }); - emit titleChanged(tr("%1 Messages (%2 DBC Messages, %3 Signals)") - .arg(model->items_.size()).arg(stats.first).arg(stats.second)); -} - -void MessagesWidget::selectMessage(const MessageId &msg_id) { - auto it = std::find_if(model->items_.cbegin(), model->items_.cend(), - [&msg_id](auto &item) { return item.id == msg_id; }); - if (it != model->items_.cend()) { - view->setCurrentIndex(model->index(std::distance(model->items_.cbegin(), it), 0)); - } -} - -void MessagesWidget::suppressHighlighted() { - int n = sender() == suppress_add ? can->suppressHighlighted() : (can->clearSuppressed(), 0); - suppress_clear->setText(n > 0 ? tr("Clear (%1)").arg(n) : tr("Clear")); - suppress_clear->setEnabled(n > 0); -} - -void MessagesWidget::headerContextMenuEvent(const QPoint &pos) { - menu->exec(header->mapToGlobal(pos)); -} - -void MessagesWidget::menuAboutToShow() { - menu->clear(); - for (int i = 0; i < header->count(); ++i) { - int logical_index = header->logicalIndex(i); - auto action = menu->addAction(model->headerData(logical_index, Qt::Horizontal).toString(), - [=](bool checked) { header->setSectionHidden(logical_index, !checked); }); - action->setCheckable(true); - action->setChecked(!header->isSectionHidden(logical_index)); - // Can't hide the name column - action->setEnabled(logical_index > 0); - } - menu->addSeparator(); - auto action = menu->addAction(tr("Multi-Line bytes"), this, &MessagesWidget::setMultiLineBytes); - action->setCheckable(true); - action->setChecked(settings.multiple_lines_hex); - - action = menu->addAction(tr("Show inactive messages"), model, &MessageListModel::showInactiveMessages); - action->setCheckable(true); - action->setChecked(model->show_inactive_messages); -} - -void MessagesWidget::setMultiLineBytes(bool multi) { - settings.multiple_lines_hex = multi; - delegate->setMultipleLines(multi); - view->updateBytesSectionSize(); - view->doItemsLayout(); -} - -// MessageListModel - -MessageListModel::MessageListModel(QObject *parent) : QAbstractTableModel(parent) { - connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool has_new_ids) { msgsReceived(msgs, has_new_ids); })); - connections_.push_back(dbc()->fileChanged.connect([this]() { dbcModified(); })); - connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { dbcModified(); })); -} - -QVariant MessageListModel::headerData(int section, Qt::Orientation orientation, int role) const { - if (orientation == Qt::Horizontal && role == Qt::DisplayRole) { - switch (section) { - case Column::NAME: return tr("Name"); - case Column::SOURCE: return tr("Bus"); - case Column::ADDRESS: return tr("ID"); - case Column::NODE: return tr("Node"); - case Column::FREQ: return tr("Freq"); - case Column::COUNT: return tr("Count"); - case Column::DATA: return tr("Bytes"); - } - } - return {}; -} - -QVariant MessageListModel::data(const QModelIndex &index, int role) const { - if (!index.isValid() || index.row() >= items_.size()) return {}; - - auto getFreq = [](float freq) { - if (freq > 0) { - return freq >= 0.95 ? QString::number(std::nearbyint(freq)) : QString::number(freq, 'f', 2); - } else { - return QStringLiteral("--"); - } - }; - - const static QString NA = QStringLiteral("N/A"); - const auto &item = items_[index.row()]; - if (role == Qt::DisplayRole) { - switch (index.column()) { - case Column::NAME: return item.name; - case Column::SOURCE: return item.id.source != INVALID_SOURCE ? QString::number(item.id.source) : NA; - case Column::ADDRESS: return QString::fromStdString(utils::toHexString(item.id.address)); - case Column::NODE: return item.node; - case Column::FREQ: return item.id.source != INVALID_SOURCE ? getFreq(can->lastMessage(item.id).freq) : NA; - case Column::COUNT: return item.id.source != INVALID_SOURCE ? QString::number(can->lastMessage(item.id).count) : NA; - case Column::DATA: return item.id.source != INVALID_SOURCE ? "" : NA; - } - } else if (role == ColorsRole) { - return QVariant::fromValue((void*)(&can->lastMessage(item.id).colors)); - } else if (role == BytesRole && index.column() == Column::DATA && item.id.source != INVALID_SOURCE) { - return QVariant::fromValue((void*)(&can->lastMessage(item.id).dat)); - } else if (role == Qt::ToolTipRole && index.column() == Column::NAME) { - auto msg = dbc()->msg(item.id); - auto tooltip = item.name; - if (msg && !msg->comment.empty()) tooltip += "
" + QString::fromStdString(msg->comment) + ""; - return tooltip; - } - return {}; -} - -void MessageListModel::setFilterStrings(const std::map &filters) { - filters_ = filters; - filterAndSort(); -} - -void MessageListModel::showInactiveMessages(bool show) { - show_inactive_messages = show; - filterAndSort(); -} - -void MessageListModel::dbcModified() { - dbc_messages_.clear(); - for (const auto &[_, m] : dbc()->getMessages(-1)) { - dbc_messages_.insert(MessageId{.source = INVALID_SOURCE, .address = m.address}); - } - filterAndSort(); -} - -void MessageListModel::sortItems(std::vector &items) { - auto compare = [this](const auto &l, const auto &r) { - switch (sort_column) { - case Column::NAME: return std::tie(l.name, l.id) < std::tie(r.name, r.id); - case Column::SOURCE: return std::tie(l.id.source, l.id.address) < std::tie(r.id.source, r.id.address); - case Column::ADDRESS: return std::tie(l.id.address, l.id.source) < std::tie(r.id.address, r.id.source); - case Column::NODE: return std::tie(l.node, l.id) < std::tie(r.node, r.id); - case Column::FREQ: return std::tie(can->lastMessage(l.id).freq, l.id) < std::tie(can->lastMessage(r.id).freq, r.id); - case Column::COUNT: return std::tie(can->lastMessage(l.id).count, l.id) < std::tie(can->lastMessage(r.id).count, r.id); - default: return false; // Default case to suppress compiler warning - } - }; - - if (sort_order == Qt::DescendingOrder) - std::stable_sort(items.rbegin(), items.rend(), compare); - else - std::stable_sort(items.begin(), items.end(), compare); -} - -static bool parseRange(const QString &filter, uint32_t value, int base = 10) { - // Parse out filter string into a range (e.g. "1" -> {1, 1}, "1-3" -> {1, 3}, "1-" -> {1, inf}) - unsigned int min = std::numeric_limits::min(); - unsigned int max = std::numeric_limits::max(); - auto s = filter.split('-'); - bool ok = s.size() >= 1 && s.size() <= 2; - if (ok && !s[0].isEmpty()) min = s[0].toUInt(&ok, base); - if (ok && s.size() == 1) { - max = min; - } else if (ok && s.size() == 2 && !s[1].isEmpty()) { - max = s[1].toUInt(&ok, base); - } - return ok && value >= min && value <= max; -} - -bool MessageListModel::match(const MessageListModel::Item &item) { - 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->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 QString::fromStdString(s->name).contains(txt, Qt::CaseInsensitive); }); - } - break; - } - case Column::SOURCE: - match = parseRange(txt, item.id.source); - break; - case Column::ADDRESS: - match = QString::fromStdString(utils::toHexString(item.id.address)).contains(txt, Qt::CaseInsensitive); - match = match || parseRange(txt, item.id.address, 16); - break; - case Column::NODE: - match = item.node.contains(txt, Qt::CaseInsensitive); - break; - case Column::FREQ: - match = parseRange(txt, data.freq); - break; - case Column::COUNT: - match = parseRange(txt, data.count); - break; - case Column::DATA: - match = QString::fromStdString(utils::toHex(data.dat)).contains(txt, Qt::CaseInsensitive); - break; - } - } - return match; -} - -bool MessageListModel::filterAndSort() { - // merge CAN and DBC messages - std::vector all_messages; - all_messages.reserve(can->lastMessages().size() + dbc_messages_.size()); - auto dbc_msgs = dbc_messages_; - for (const auto &[id, m] : can->lastMessages()) { - all_messages.push_back(id); - dbc_msgs.erase(MessageId{.source = INVALID_SOURCE, .address = id.address}); - } - all_messages.insert(all_messages.end(), dbc_msgs.begin(), dbc_msgs.end()); - - // filter and sort - std::vector items; - items.reserve(all_messages.size()); - for (const auto &id : all_messages) { - if (show_inactive_messages || can->isMessageActive(id)) { - auto msg = dbc()->msg(id); - Item item = {.id = id, - .name = msg ? QString::fromStdString(msg->name) : QString::fromStdString(UNTITLED), - .node = msg ? QString::fromStdString(msg->transmitter) : QString()}; - if (match(item)) - items.emplace_back(item); - } - } - sortItems(items); - - if (items_ != items) { - beginResetModel(); - items_ = std::move(items); - endResetModel(); - return true; - } - return false; -} - -void MessageListModel::msgsReceived(const std::set *new_msgs, bool has_new_ids) { - if (has_new_ids || ((filters_.count(Column::FREQ) || filters_.count(Column::COUNT) || filters_.count(Column::DATA)) && - ++sort_threshold_ == STREAM_UPDATE_FPS)) { - sort_threshold_ = 0; - if (filterAndSort()) return; - } - - // Update viewport - emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1)); -} - -void MessageListModel::sort(int column, Qt::SortOrder order) { - if (column != Column::DATA) { - sort_column = column; - sort_order = order; - filterAndSort(); - } -} - -// MessageView - -void MessageView::drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - const auto &item = ((MessageListModel*)model())->items_[index.row()]; - if (!can->isMessageActive(item.id)) { - QStyleOptionViewItem custom_option = option; - custom_option.palette.setBrush(QPalette::Text, custom_option.palette.color(QPalette::Disabled, QPalette::Text)); - auto color = QApplication::palette().color(QPalette::HighlightedText); - color.setAlpha(100); - custom_option.palette.setBrush(QPalette::HighlightedText, color); - QTreeView::drawRow(painter, custom_option, index); - } else { - QTreeView::drawRow(painter, option, index); - } - - QPen oldPen = painter->pen(); - const int gridHint = style()->styleHint(QStyle::SH_Table_GridLineColor, &option, this); - painter->setPen(QColor::fromRgba(static_cast(gridHint))); - // Draw bottom border for the row - painter->drawLine(option.rect.bottomLeft(), option.rect.bottomRight()); - // Draw vertical borders for each column - for (int i = 0; i < header()->count(); ++i) { - int sectionX = header()->sectionViewportPosition(i); - painter->drawLine(sectionX, option.rect.top(), sectionX, option.rect.bottom()); - } - painter->setPen(oldPen); -} - -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. - 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() { - auto delegate = ((MessageBytesDelegate *)itemDelegate()); - int max_bytes = 8; - if (!delegate->multipleLines()) { - for (const auto &[_, m] : can->lastMessages()) { - max_bytes = std::max(max_bytes, m.dat.size()); - } - } - setUniformRowHeights(!delegate->multipleLines()); - header()->resizeSection(MessageListModel::Column::DATA, delegate->sizeForBytes(max_bytes).width()); -} - -void MessageView::wheelEvent(QWheelEvent *event) { - if (event->modifiers() == Qt::ShiftModifier) { - QApplication::sendEvent(horizontalScrollBar(), event); - } else { - QTreeView::wheelEvent(event); - } -} - -// MessageViewHeader - -MessageViewHeader::MessageViewHeader(QWidget *parent) : QHeaderView(Qt::Horizontal, parent) { - QObject::connect(this, &QHeaderView::sectionResized, this, &MessageViewHeader::updateHeaderPositions); - QObject::connect(this, &QHeaderView::sectionMoved, this, &MessageViewHeader::updateHeaderPositions); -} - -void MessageViewHeader::updateFilters() { - std::map filters; - for (int i = 0; i < (int)editors.size(); i++) { - if (!editors[i]->text().isEmpty()) { - filters[i] = editors[i]->text(); - } - } - qobject_cast(model())->setFilterStrings(filters); -} - -void MessageViewHeader::updateHeaderPositions() { - QSize sz = QHeaderView::sizeHint(); - 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 = (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(edit, &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters); - editors.push_back(edit); - } - setViewportMargins(0, 0, 0, !editors.empty() ? editors[0]->sizeHint().height() : 0); - - QHeaderView::updateGeometries(); - updateHeaderPositions(); -} - -QSize MessageViewHeader::sizeHint() const { - QSize sz = QHeaderView::sizeHint(); - return !editors.empty() ? QSize(sz.width(), sz.height() + editors[0]->height() + 1) : sz; -} diff --git a/openpilot/tools/cabana/messageswidget.h b/openpilot/tools/cabana/messageswidget.h deleted file mode 100644 index 7c9fc0253f..0000000000 --- a/openpilot/tools/cabana/messageswidget.h +++ /dev/null @@ -1,131 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/utils/qtutil.h" - -class MessageListModel : public QAbstractTableModel { -Q_OBJECT - -public: - enum Column { - NAME = 0, - SOURCE, - ADDRESS, - NODE, - FREQ, - COUNT, - DATA, - }; - - MessageListModel(QObject *parent); - QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return Column::DATA + 1; } - 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 std::map &filters); - void showInactiveMessages(bool show); - void msgsReceived(const std::set *new_msgs, bool has_new_ids); - bool filterAndSort(); - void dbcModified(); - - struct Item { - MessageId id; - QString name; - QString node; - bool operator==(const Item &other) const { - return id == other.id && name == other.name && node == other.node; - } - }; - std::vector items_; - bool show_inactive_messages = true; - -private: - void sortItems(std::vector &items); - bool match(const MessageListModel::Item &id); - - std::map filters_; - std::set dbc_messages_; - int sort_column = 0; - Qt::SortOrder sort_order = Qt::AscendingOrder; - int sort_threshold_ = 0; - Connections connections_; -}; - -class MessageView : public QTreeView { - Q_OBJECT -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 wheelEvent(QWheelEvent *event) override; -}; - -class MessageViewHeader : public QHeaderView { - // https://stackoverflow.com/a/44346317 - Q_OBJECT -public: - MessageViewHeader(QWidget *parent); - void updateHeaderPositions(); - void updateGeometries() override; - QSize sizeHint() const override; - void updateFilters(); - - std::vector editors; -}; - -class MessagesWidget : public QWidget { - Q_OBJECT - -public: - MessagesWidget(QWidget *parent); - void selectMessage(const MessageId &message_id); - 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: - void msgSelectionChanged(const MessageId &message_id); - void titleChanged(const QString &title); - -protected: - QWidget *createToolBar(); - void headerContextMenuEvent(const QPoint &pos); - void menuAboutToShow(); - void setMultiLineBytes(bool multi); - void updateTitle(); - - MessageView *view; - MessageViewHeader *header; - MessageBytesDelegate *delegate; - std::optional current_msg_id; - MessageListModel *model; - QPushButton *suppress_add; - QPushButton *suppress_clear; - QMenu *menu; -}; diff --git a/openpilot/tools/cabana/routesdialog.cc b/openpilot/tools/cabana/routesdialog.cc deleted file mode 100644 index 33b773edb9..0000000000 --- a/openpilot/tools/cabana/routesdialog.cc +++ /dev/null @@ -1,110 +0,0 @@ -#include "tools/cabana/routesdialog.h" - -#include -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/utils/util.h" - -// The RouteListWidget class extends QListWidget to display a custom message when empty -class RouteListWidget : public QListWidget { -public: - RouteListWidget(QWidget *parent = nullptr) : QListWidget(parent) {} - void setEmptyText(const QString &text) { - empty_text_ = text; - viewport()->update(); - } - void paintEvent(QPaintEvent *event) override { - QListWidget::paintEvent(event); - if (count() == 0) { - QPainter painter(viewport()); - painter.drawText(viewport()->rect(), Qt::AlignCenter, empty_text_); - } - } - QString empty_text_ = tr("No items"); -}; - -RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { - setWindowTitle(tr("Remote routes")); - - QFormLayout *layout = new QFormLayout(this); - layout->addRow(tr("Device"), device_list_ = new QComboBox(this)); - layout->addRow(period_selector_ = new QComboBox(this)); - layout->addRow(route_list_ = new RouteListWidget(this)); - auto button_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - layout->addRow(button_box); - - device_list_->addItem(tr("Loading...")); - 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(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); - connect(button_box, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); - - routes::fetchDevices([this, alive = std::weak_ptr(alive_)](std::vector devices, bool success, int error_code) { - utils::runOnMainThread([this, alive, devices = std::move(devices), success, error_code]() { - if (!alive.expired()) setDeviceList(devices, success, error_code); - }); - }); -} - -void RoutesDialog::setDeviceList(const std::vector &devices, bool success, int error_code) { - if (success) { - device_list_->clear(); - for (const auto &device : devices) { - QString dongle_id = QString::fromStdString(device.dongle_id); - device_list_->addItem(dongle_id, dongle_id); - } - } else { - QMessageBox::warning(this, tr("Error"), error_code == 401 ? tr("Unauthorized. Authenticate with openpilot/tools/lib/auth.py") : tr("Network error")); - reject(); - } -} - -void RoutesDialog::fetchRoutes() { - if (device_list_->currentIndex() == -1 || device_list_->currentData().isNull()) - return; - - route_list_->clear(); - route_list_->setEmptyText(tr("Loading...")); - - int request_id = ++fetch_id_; - auto on_routes = [this, alive = std::weak_ptr(alive_), request_id](std::vector list, bool success, int) { - utils::runOnMainThread([this, alive, list = std::move(list), success, request_id]() { - if (!alive.expired() && fetch_id_ == request_id) setRouteList(list, success); - }); - }; - routes::fetchRoutes(device_list_->currentText().toStdString(), period_selector_->currentData().toInt(), std::move(on_routes)); -} - -void RoutesDialog::setRouteList(const std::vector &list, bool success) { - if (success) { - for (const auto &route : list) { - const int mins = static_cast((route.end_ms - route.start_ms) / 60000); - auto item = new QListWidgetItem(QString::fromStdString(routes::formatUnixMs(route.start_ms) + " " + std::to_string(mins) + "min")); - item->setData(Qt::UserRole, QString::fromStdString(route.name)); - route_list_->addItem(item); - } - if (route_list_->count() > 0) route_list_->setCurrentRow(0); - } else { - QMessageBox::warning(this, tr("Error"), tr("Failed to fetch routes. Check your network connection.")); - reject(); - } - route_list_->setEmptyText(tr("No items")); -} - -std::string RoutesDialog::route() { - auto current_item = route_list_->currentItem(); - return current_item ? current_item->data(Qt::UserRole).toString().toStdString() : ""; -} diff --git a/openpilot/tools/cabana/routesdialog.h b/openpilot/tools/cabana/routesdialog.h deleted file mode 100644 index 4983f1b558..0000000000 --- a/openpilot/tools/cabana/routesdialog.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include - -#include "tools/cabana/routes.h" - -class RouteListWidget; - -class RoutesDialog : public QDialog { - Q_OBJECT -public: - RoutesDialog(QWidget *parent); - std::string route(); - -protected: - void setDeviceList(const std::vector &devices, bool success, int error_code); - void setRouteList(const std::vector &list, bool success); - void fetchRoutes(); - - QComboBox *device_list_; - QComboBox *period_selector_; - RouteListWidget *route_list_; - 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/openpilot/tools/cabana/settingsdialog.cc b/openpilot/tools/cabana/settingsdialog.cc deleted file mode 100644 index 0fbc82c508..0000000000 --- a/openpilot/tools/cabana/settingsdialog.cc +++ /dev/null @@ -1,88 +0,0 @@ -#include "tools/cabana/settingsdialog.h" - -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/settings.h" -#include "tools/cabana/utils/qtutil.h" - -const int MIN_CACHE_MINIUTES = 30; -const int MAX_CACHE_MINIUTES = 120; - -SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) { - setWindowTitle(tr("Settings")); - QVBoxLayout *main_layout = new QVBoxLayout(this); - QGroupBox *groupbox = new QGroupBox("General"); - QFormLayout *form_layout = new QFormLayout(groupbox); - - form_layout->addRow(tr("Color Theme"), theme = new QComboBox(this)); - theme->setToolTip(tr("You may need to restart cabana after changes theme")); - theme->addItems({tr("Light"), tr("Dark")}); - theme->setCurrentIndex(settings.theme - LIGHT_THEME); - - form_layout->addRow(tr("Max Cached Minutes"), cached_minutes = new QSpinBox(this)); - cached_minutes->setRange(MIN_CACHE_MINIUTES, MAX_CACHE_MINIUTES); - cached_minutes->setSingleStep(1); - cached_minutes->setValue(settings.max_cached_minutes); - main_layout->addWidget(groupbox); - - groupbox = new QGroupBox("New Signal Settings"); - form_layout = new QFormLayout(groupbox); - form_layout->addRow(tr("Drag Direction"), drag_direction = new QComboBox(this)); - drag_direction->addItems({tr("MSB First"), tr("LSB First"), tr("Always Little Endian"), tr("Always Big Endian")}); - drag_direction->setCurrentIndex(settings.drag_direction); - main_layout->addWidget(groupbox); - - groupbox = new QGroupBox("Chart"); - form_layout = new QFormLayout(groupbox); - form_layout->addRow(tr("Chart Height"), chart_height = new QSpinBox(this)); - chart_height->setRange(100, 500); - chart_height->setSingleStep(10); - chart_height->setValue(settings.chart_height); - main_layout->addWidget(groupbox); - - 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(QString::fromStdString(settings.log_path), this)); - log_path->setReadOnly(true); - auto browse_btn = new QPushButton(tr("B&rowse...")); - path_layout->addWidget(browse_btn); - main_layout->addWidget(log_livestream); - - auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - main_layout->addWidget(buttonBox); - setFixedSize(400, sizeHint().height()); - - QObject::connect(browse_btn, &QPushButton::clicked, [this]() { - QString fn = QFileDialog::getExistingDirectory( - this, tr("Log File Location"), - QString::fromStdString(utils::homePath()), - QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); - if (!fn.isEmpty()) { - log_path->setText(fn); - } - }); - QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); - QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &SettingsDialog::save); -} - -void SettingsDialog::save() { - if (std::exchange(settings.theme, theme->currentIndex() + LIGHT_THEME) != settings.theme) { - // set theme before emit changed - utils::setTheme(settings.theme); - } - 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().toStdString(); - settings.drag_direction = (Settings::DragDirection)drag_direction->currentIndex(); - settings.changed(); - QDialog::accept(); -} diff --git a/openpilot/tools/cabana/settingsdialog.h b/openpilot/tools/cabana/settingsdialog.h deleted file mode 100644 index 895b789f30..0000000000 --- a/openpilot/tools/cabana/settingsdialog.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -class SettingsDialog : public QDialog { -public: - SettingsDialog(QWidget *parent); - void save(); - QSpinBox *cached_minutes; - QSpinBox *chart_height; - QComboBox *chart_series_type; - QComboBox *theme; - QGroupBox *log_livestream; - QLineEdit *log_path; - QComboBox *drag_direction; -}; diff --git a/openpilot/tools/cabana/signalview.cc b/openpilot/tools/cabana/signalview.cc deleted file mode 100644 index de80ce2a3f..0000000000 --- a/openpilot/tools/cabana/signalview.cc +++ /dev/null @@ -1,719 +0,0 @@ -#include "tools/cabana/signalview.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/utils/qtutil.h" - -// SignalModel - -static QString signalTypeToString(cabana::Signal::Type type) { - if (type == cabana::Signal::Type::Multiplexor) return "Multiplexor Signal"; - else if (type == cabana::Signal::Type::Multiplexed) return "Multiplexed Signal"; - else return "Normal Signal"; -} - -SignalModel::SignalModel(QObject *parent) : root(new Item), QAbstractItemModel(parent) { - connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); - connections_.push_back(dbc()->msgUpdated.connect([this](MessageId id) { handleMsgChanged(id); })); - connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { handleMsgChanged(id); })); - connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); - connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); - connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { handleSignalRemoved(sig); })); -} - -void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) { - 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{.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; - } - } -} - -void SignalModel::setMessage(const MessageId &id) { - msg_id = id; - filter_str = ""; - refresh(); -} - -void SignalModel::setFilter(const QString &txt) { - filter_str = txt; - refresh(); -} - -void SignalModel::refresh() { - beginResetModel(); - root.reset(new SignalModel::Item); - if (auto msg = dbc()->msg(msg_id)) { - for (auto s : msg->getSignals()) { - if (filter_str.isEmpty() || QString::fromStdString(s->name).contains(filter_str, Qt::CaseInsensitive)) { - insertItem(root.get(), root->children.size(), s); - } - } - } - endResetModel(); -} - -SignalModel::Item *SignalModel::getItem(const QModelIndex &index) const { - auto item = index.isValid() ? (SignalModel::Item *)index.internalPointer() : nullptr; - return item ? item : root.get(); -} - -int SignalModel::rowCount(const QModelIndex &parent) const { - if (parent.isValid() && parent.column() > 0) return 0; - - return getItem(parent)->children.size(); -} - -Qt::ItemFlags SignalModel::flags(const QModelIndex &index) const { - if (!index.isValid()) return Qt::NoItemFlags; - - auto item = getItem(index); - Qt::ItemFlags flags = Qt::ItemIsSelectable | Qt::ItemIsEnabled; - if (index.column() == 1 && item->children.empty()) { - flags |= (item->type == Item::Endian || item->type == Item::Signed) ? Qt::ItemIsUserCheckable : Qt::ItemIsEditable; - } - if (item->type == Item::MultiplexValue && item->sig->type != cabana::Signal::Type::Multiplexed) { - flags &= ~Qt::ItemIsEnabled; - } - return flags; -} - -int SignalModel::signalRow(const cabana::Signal *sig) const { - for (int i = 0; i < root->children.size(); ++i) { - if (root->children[i]->sig == sig) return i; - } - return -1; -} - -QModelIndex SignalModel::index(int row, int column, const QModelIndex &parent) const { - if (parent.isValid() && parent.column() != 0) return {}; - - auto parent_item = getItem(parent); - if (parent_item && row < parent_item->children.size()) { - return createIndex(row, column, parent_item->children[row]); - } - return {}; -} - -QModelIndex SignalModel::parent(const QModelIndex &index) const { - if (!index.isValid()) return {}; - Item *parent_item = getItem(index)->parent; - return !parent_item || parent_item == root.get() ? QModelIndex() : createIndex(parent_item->row(), 0, parent_item); -} - -QVariant SignalModel::data(const QModelIndex &index, int role) const { - if (index.isValid()) { - const Item *item = getItem(index); - if (role == Qt::DisplayRole || role == Qt::EditRole) { - if (index.column() == 0) { - 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 QString::fromStdString(item->sig->name); - case Item::Size: return item->sig->size; - 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 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(QString::fromStdString(desc)); - } - return val_desc.join(" "); - } - default: break; - } - } - } else if (role == Qt::CheckStateRole && index.column() == 1) { - if (item->type == Item::Endian) return item->sig->is_little_endian ? Qt::Checked : Qt::Unchecked; - if (item->type == Item::Signed) return item->sig->is_signed ? Qt::Checked : Qt::Unchecked; - } else if (role == Qt::ToolTipRole && item->type == Item::Sig) { - return (index.column() == 0) ? QString::fromStdString(utils::signalToolTip(item->sig)) : QString(); - } - } - return {}; -} - -bool SignalModel::setData(const QModelIndex &index, const QVariant &value, int role) { - if (role != Qt::EditRole && role != Qt::CheckStateRole) return false; - - Item *item = getItem(index); - cabana::Signal s = *item->sig; - switch (item->type) { - 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().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().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; - default: return false; - } - bool ret = saveSignal(item->sig, s); - emit dataChanged(index, index, {Qt::DisplayRole, Qt::EditRole, Qt::CheckStateRole}); - return ret; -} - -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(QString::fromStdString(s.name)); - QMessageBox::warning(nullptr, tr("Failed to save signal"), text); - return false; - } - - if (s.is_little_endian != origin_s->is_little_endian) { - s.start_bit = flipBitPos(s.start_bit); - } - UndoStack::instance()->push(new EditSignalCommand(msg_id, origin_s, s)); - return true; -} - -void SignalModel::handleMsgChanged(MessageId id) { - if (id.address == msg_id.address) { - refresh(); - } -} - -void SignalModel::handleSignalAdded(MessageId id, const cabana::Signal *sig) { - if (id == msg_id) { - if (filter_str.isEmpty()) { - int i = dbc()->msg(msg_id)->indexOf(sig); - beginInsertRows({}, i, i); - insertItem(root.get(), i, sig); - endInsertRows(); - } else if (QString::fromStdString(sig->name).contains(filter_str, Qt::CaseInsensitive)) { - refresh(); - } - } -} - -void SignalModel::handleSignalUpdated(const cabana::Signal *sig) { - if (int row = signalRow(sig); row != -1) { - emit dataChanged(index(row, 0), index(row, 1), {Qt::DisplayRole, Qt::EditRole, Qt::CheckStateRole}); - - if (filter_str.isEmpty()) { - // move row when the order changes. - int to = dbc()->msg(msg_id)->indexOf(sig); - if (to != row) { - beginMoveRows({}, row, row, {}, to > row ? to + 1 : to); - auto item = root->children[row]; - root->children.erase(root->children.begin() + row); - root->children.insert(root->children.begin() + to, item); - endMoveRows(); - } - } - } -} - -void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { - if (int row = signalRow(sig); row != -1) { - beginRemoveRows({}, row, row); - delete root->children[row]; - root->children.erase(root->children.begin() + row); - endRemoveRows(); - } -} - -// SignalItemDelegate - -SignalItemDelegate::SignalItemDelegate(QObject *parent) : QStyledItemDelegate(parent) { - name_validator = new NameValidator(this); - node_validator = new NodeValidator(this); - double_validator = new DoubleValidator(this); - - label_font.setPointSize(8); - minmax_font.setPixelSize(10); -} - -QSize SignalItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { - int width = option.widget->size().width() / 2; - if (index.column() == 0) { - int spacing = option.widget->style()->pixelMetric(QStyle::PM_TreeViewIndentation) + color_label_width + 8; - auto text = index.data(Qt::DisplayRole).toString(); - auto item = (SignalModel::Item *)index.internalPointer(); - if (item->type == SignalModel::Item::Sig && item->sig->type != cabana::Signal::Type::Normal) { - text += item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value); - spacing += (option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1) * 2; - } - width = std::min(option.widget->size().width() / 3.0, option.fontMetrics.horizontalAdvance(text) + spacing); - } - return {width, option.fontMetrics.height() + option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2}; -} - -void SignalItemDelegate::updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto item = (SignalModel::Item *)index.internalPointer(); - if (editor && item->type == SignalModel::Item::Sig && index.column() == 1) { - QRect geom = option.rect; - geom.setLeft(geom.right() - editor->sizeHint().width()); - editor->setGeometry(geom); - button_size = geom.size(); - return; - } - QStyledItemDelegate::updateEditorGeometry(editor, option, index); -} - -void SignalItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - const int h_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1; - const int v_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin); - auto item = static_cast(index.internalPointer()); - - QRect rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin); - painter->setRenderHint(QPainter::Antialiasing); - if (option.state & QStyle::State_Selected) { - painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight)); - } - - if (index.column() == 0) { - if (item->type == SignalModel::Item::Sig) { - // color label - QPainterPath path; - QRect icon_rect{rect.x(), rect.y(), color_label_width, rect.height()}; - path.addRoundedRect(icon_rect, 3, 3); - painter->setPen(item->highlight ? Qt::white : Qt::black); - painter->setFont(label_font); - 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); - // multiplexer indicator - if (item->sig->type != cabana::Signal::Type::Normal) { - QString indicator = item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value); - QRect indicator_rect{rect.x(), rect.y(), option.fontMetrics.horizontalAdvance(indicator), rect.height()}; - painter->setBrush(Qt::gray); - painter->setPen(Qt::NoPen); - painter->drawRoundedRect(indicator_rect, 3, 3); - painter->setPen(Qt::white); - painter->drawText(indicator_rect, Qt::AlignCenter, indicator); - rect.setLeft(indicator_rect.right() + h_margin * 2); - } - } else { - rect.setLeft(option.widget->style()->pixelMetric(QStyle::PM_TreeViewIndentation) + color_label_width + h_margin * 3); - } - - // name - auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, rect.width()); - painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text)); - painter->setFont(option.font); - painter->drawText(rect, option.displayAlignment, text); - } else if (index.column() == 1) { - if (!item->sparkline.pixmap.isNull()) { - QSize sparkline_size = item->sparkline.pixmap.size() / item->sparkline.pixmap.devicePixelRatio(); - painter->drawPixmap(QRect(rect.topLeft(), sparkline_size), item->sparkline.pixmap); - // min-max value - painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text)); - rect.adjust(sparkline_size.width() + 1, 0, 0, 0); - int value_adjust = 10; - if (!item->sparkline.isEmpty() && (item->highlight || option.state & QStyle::State_Selected)) { - painter->drawLine(rect.topLeft(), rect.bottomLeft()); - rect.adjust(5, -v_margin, 0, v_margin); - painter->setFont(minmax_font); - QString min = QString::number(item->sparkline.min_val); - QString max = QString::number(item->sparkline.max_val); - painter->drawText(rect, Qt::AlignLeft | Qt::AlignTop, max); - painter->drawText(rect, Qt::AlignLeft | Qt::AlignBottom, min); - QFontMetrics fm(minmax_font); - value_adjust = std::max(fm.horizontalAdvance(min), fm.horizontalAdvance(max)) + 5; - } else if (!item->sparkline.isEmpty() && item->sig->type == cabana::Signal::Type::Multiplexed) { - // display freq of multiplexed signal - painter->setFont(label_font); - QString freq = QString("%1 hz").arg(item->sparkline.freq(), 0, 'g', 2); - painter->drawText(rect.adjusted(5, 0, 0, 0), Qt::AlignLeft | Qt::AlignVCenter, freq); - value_adjust = QFontMetrics(label_font).horizontalAdvance(freq) + 10; - } - // signal value - painter->setFont(option.font); - rect.adjust(value_adjust, 0, -button_size.width(), 0); - auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, rect.width()); - painter->drawText(rect, Qt::AlignRight | Qt::AlignVCenter, text); - } else { - QStyledItemDelegate::paint(painter, option, index); - } - } -} - -QWidget *SignalItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto item = (SignalModel::Item *)index.internalPointer(); - if (item->type == SignalModel::Item::Name || item->type == SignalModel::Item::Node || item->type == SignalModel::Item::Offset || - item->type == SignalModel::Item::Factor || item->type == SignalModel::Item::MultiplexValue || - item->type == SignalModel::Item::Min || item->type == SignalModel::Item::Max) { - QLineEdit *e = new QLineEdit(parent); - e->setFrame(false); - if (item->type == SignalModel::Item::Name) e->setValidator(name_validator); - else if (item->type == SignalModel::Item::Node) e->setValidator(node_validator); - else e->setValidator(double_validator); - - return e; - } else if (item->type == SignalModel::Item::Size) { - QSpinBox *spin = new QSpinBox(parent); - spin->setFrame(false); - spin->setRange(1, CAN_MAX_DATA_BYTES); - return spin; - } else if (item->type == SignalModel::Item::SignalType) { - QComboBox *c = new QComboBox(parent); - c->addItem(signalTypeToString(cabana::Signal::Type::Normal), (int)cabana::Signal::Type::Normal); - if (!dbc()->msg(((SignalModel *)index.model())->msg_id)->multiplexor) { - c->addItem(signalTypeToString(cabana::Signal::Type::Multiplexor), (int)cabana::Signal::Type::Multiplexor); - } else if (item->sig->type != cabana::Signal::Type::Multiplexor) { - c->addItem(signalTypeToString(cabana::Signal::Type::Multiplexed), (int)cabana::Signal::Type::Multiplexed); - } - return c; - } else if (item->type == SignalModel::Item::Desc) { - ValueDescriptionDlg dlg(item->sig->val_desc, parent); - dlg.setWindowTitle(QString::fromStdString(item->sig->name)); - if (dlg.exec()) { - ((QAbstractItemModel *)index.model())->setData(index, QVariant::fromValue(dlg.val_desc)); - } - return nullptr; - } - return QStyledItemDelegate::createEditor(parent, option, index); -} - -void SignalItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const { - auto item = (SignalModel::Item *)index.internalPointer(); - if (item->type == SignalModel::Item::SignalType) { - model->setData(index, ((QComboBox*)editor)->currentData().toInt()); - return; - } - QStyledItemDelegate::setModelData(editor, model, index); -} - -// SignalView - -SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QFrame(parent) { - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - // title bar - QWidget *title_bar = new QWidget(this); - QHBoxLayout *hl = new QHBoxLayout(title_bar); - hl->addWidget(signal_count_lb = new QLabel()); - filter_edit = new QLineEdit(this); - filter_edit->setValidator(new NonWhitespaceValidator(this)); - filter_edit->setClearButtonEnabled(true); - filter_edit->setPlaceholderText(tr("Filter Signal")); - hl->addWidget(filter_edit); - hl->addStretch(1); - - // WARNING: increasing the maximum range can result in severe performance degradation. - // 30s is a reasonable value at present. - const int max_range = 30; // 30s - settings.sparkline_range = std::clamp(settings.sparkline_range, 1, max_range); - hl->addWidget(sparkline_label = new QLabel()); - hl->addWidget(sparkline_range_slider = new QSlider(Qt::Horizontal, this)); - sparkline_range_slider->setRange(1, max_range); - sparkline_range_slider->setValue(settings.sparkline_range); - sparkline_range_slider->setToolTip(tr("Sparkline time range")); - - auto collapse_btn = new ToolButton("dash-square", tr("Collapse All")); - collapse_btn->setIconSize({12, 12}); - hl->addWidget(collapse_btn); - - // tree view - tree = new TreeView(this); - tree->setModel(model = new SignalModel(this)); - tree->setItemDelegate(delegate = new SignalItemDelegate(this)); - tree->setFrameShape(QFrame::NoFrame); - tree->setHeaderHidden(true); - tree->setMouseTracking(true); - tree->setExpandsOnDoubleClick(false); - tree->setEditTriggers(QAbstractItemView::AllEditTriggers); - tree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); - tree->header()->setStretchLastSection(true); - tree->setMinimumHeight(300); - - // Use a distinctive background for the whole row containing a QSpinBox or QLineEdit - QString nodeBgColor = palette().color(QPalette::AlternateBase).name(QColor::HexArgb); - tree->setStyleSheet(QString("QSpinBox{background-color:%1;border:none;} QLineEdit{background-color:%1;}").arg(nodeBgColor)); - - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - main_layout->addWidget(title_bar); - main_layout->addWidget(tree); - updateToolBar(); - - QObject::connect(filter_edit, &QLineEdit::textEdited, model, &SignalModel::setFilter); - QObject::connect(sparkline_range_slider, &QSlider::valueChanged, this, &SignalView::setSparklineRange); - QObject::connect(collapse_btn, &QPushButton::clicked, tree, &QTreeView::collapseAll); - QObject::connect(tree, &QAbstractItemView::clicked, this, &SignalView::rowClicked); - QObject::connect(tree, &QTreeView::viewportEntered, [this]() { emit highlight(nullptr); }); - 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); - connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); - connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); - QObject::connect(tree->verticalScrollBar(), &QScrollBar::valueChanged, [this]() { updateState(); }); - QObject::connect(tree->verticalScrollBar(), &QScrollBar::rangeChanged, [this]() { updateState(); }); - connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); - QObject::connect(tree->header(), &QHeaderView::sectionResized, [this](int logicalIndex, int oldSize, int newSize) { - if (logicalIndex == 1) { - value_column_width = newSize; - updateState(); - } - }); - - setWhatsThis(tr(R"( - Signal view
- - )")); -} - -void SignalView::setMessage(const MessageId &id) { - max_value_width = 0; - filter_edit->clear(); - model->setMessage(id); -} - -void SignalView::rowsChanged() { - for (int i = 0; i < model->rowCount(); ++i) { - auto index = model->index(i, 1); - if (!tree->indexWidget(index)) { - QWidget *w = new QWidget(this); - QHBoxLayout *h = new QHBoxLayout(w); - int v_margin = style()->pixelMetric(QStyle::PM_FocusFrameVMargin); - int h_margin = style()->pixelMetric(QStyle::PM_FocusFrameHMargin); - h->setContentsMargins(0, v_margin, -h_margin, v_margin); - h->setSpacing(style()->pixelMetric(QStyle::PM_ToolBarItemSpacing)); - - auto remove_btn = new ToolButton("x", tr("Remove signal")); - auto plot_btn = new ToolButton("graph-up", ""); - plot_btn->setCheckable(true); - h->addWidget(plot_btn); - h->addWidget(remove_btn); - - tree->setIndexWidget(index, w); - auto sig = model->getItem(index)->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); - }); - } - } - updateToolBar(); - updateChartState(); - updateState(); -} - -void SignalView::rowClicked(const QModelIndex &index) { - auto item = model->getItem(index); - if (item->type == SignalModel::Item::Sig || item->type == SignalModel::Item::ExtraInfo) { - auto expand_index = model->index(index.row(), 0, index.parent()); - tree->setExpanded(expand_index, !tree->isExpanded(expand_index)); - } -} - -void SignalView::selectSignal(const cabana::Signal *sig, bool expand) { - if (int row = model->signalRow(sig); row != -1) { - auto idx = model->index(row, 0); - if (expand) { - tree->setExpanded(idx, !tree->isExpanded(idx)); - } - tree->scrollTo(idx, QAbstractItemView::PositionAtTop); - tree->setCurrentIndex(idx); - } -} - -void SignalView::updateChartState() { - int i = 0; - for (auto item : model->root->children) { - bool chart_opened = charts->hasSignal(model->msg_id, item->sig); - auto buttons = tree->indexWidget(model->index(i, 1))->findChildren(); - if (buttons.size() > 0) { - buttons[0]->setChecked(chart_opened); - buttons[0]->setToolTip(chart_opened ? tr("Close Plot") : tr("Show Plot\nSHIFT click to add to previous opened plot")); - } - ++i; - } -} - -void SignalView::signalHovered(const cabana::Signal *sig) { - auto &children = model->root->children; - for (int i = 0; i < children.size(); ++i) { - bool highlight = children[i]->sig == sig; - if (std::exchange(children[i]->highlight, highlight) != highlight) { - emit model->dataChanged(model->index(i, 0), model->index(i, 0), {Qt::DecorationRole}); - emit model->dataChanged(model->index(i, 1), model->index(i, 1), {Qt::DisplayRole}); - } - } -} - -void SignalView::updateToolBar() { - signal_count_lb->setText(tr("Signals: %1").arg(model->rowCount())); - sparkline_label->setText(QString::fromStdString(utils::formatSeconds(settings.sparkline_range))); -} - -void SignalView::setSparklineRange(int value) { - settings.sparkline_range = value; - updateToolBar(); - updateState(); -} - -void SignalView::handleSignalAdded(MessageId id, const cabana::Signal *sig) { - if (id.address == model->msg_id.address) { - selectSignal(sig); - } -} - -void SignalView::handleSignalUpdated(const cabana::Signal *sig) { - if (int row = model->signalRow(sig); row != -1) - updateState(); -} - -std::pair SignalView::visibleSignalRange() { - auto topLevelIndex = [](QModelIndex index) { - while (index.isValid() && index.parent().isValid()) index = index.parent(); - return index; - }; - - const auto viewport_rect = tree->viewport()->rect(); - QModelIndex first_visible = tree->indexAt(viewport_rect.topLeft()); - if (first_visible.parent().isValid()) { - first_visible = topLevelIndex(first_visible); - first_visible = first_visible.siblingAtRow(first_visible.row() + 1); - } - - QModelIndex last_visible = topLevelIndex(tree->indexAt(viewport_rect.bottomRight())); - if (!last_visible.isValid()) { - last_visible = model->index(model->rowCount() - 1, 0); - } - return {first_visible, last_visible}; -} - -void SignalView::updateState(const std::set *msgs) { - const auto &last_msg = can->lastMessage(model->msg_id); - if (model->rowCount() == 0 || (msgs && !msgs->count(model->msg_id)) || last_msg.dat.size() == 0) return; - - 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 = QString::fromStdString(item->sig->formatValue(value)); - max_value_width = std::max(max_value_width, fontMetrics().horizontalAdvance(item->sig_val)); - } - } - - auto [first_visible, last_visible] = visibleSignalRange(); - if (first_visible.isValid() && last_visible.isValid()) { - const static int min_max_width = QFontMetrics(delegate->minmax_font).horizontalAdvance("-000.00") + 5; - int available_width = value_column_width - delegate->button_size.width(); - int value_width = std::min(max_value_width + min_max_width, available_width / 2); - QSize size(available_width - value_width, - 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)); - std::vector> futures; - for (int i = first_visible.row(); i <= last_visible.row(); ++i) { - auto item = model->getItem(model->index(i, 1)); - futures.push_back(std::async(std::launch::async, - &Sparkline::update, &item->sparkline, item->sig, first, last, settings.sparkline_range, size)); - } - for (auto &f : futures) f.get(); - } - - for (int i = 0; i < model->rowCount(); ++i) { - emit model->dataChanged(model->index(i, 1), model->index(i, 1), {Qt::DisplayRole}); - } -} - -void SignalView::resizeEvent(QResizeEvent* event) { - updateState(); - QFrame::resizeEvent(event); -} - -// ValueDescriptionDlg - -ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions, QWidget *parent) : QDialog(parent) { - QHBoxLayout *toolbar_layout = new QHBoxLayout(); - QPushButton *add = new QPushButton(utils::icon("plus"), ""); - QPushButton *remove = new QPushButton(utils::icon("dash"), ""); - remove->setEnabled(false); - toolbar_layout->addWidget(add); - toolbar_layout->addWidget(remove); - toolbar_layout->addStretch(0); - - table = new QTableWidget(descriptions.size(), 2, this); - table->setItemDelegate(new Delegate(this)); - table->setHorizontalHeaderLabels({"Value", "Description"}); - table->horizontalHeader()->setStretchLastSection(true); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed); - table->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - - int row = 0; - for (auto &[val, desc] : descriptions) { - table->setItem(row, 0, new QTableWidgetItem(QString::number(val))); - table->setItem(row, 1, new QTableWidgetItem(QString::fromStdString(desc))); - ++row; - } - - auto btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->addLayout(toolbar_layout); - main_layout->addWidget(table); - main_layout->addWidget(btn_box); - setMinimumWidth(500); - - QObject::connect(btn_box, &QDialogButtonBox::accepted, this, &ValueDescriptionDlg::save); - QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject); - QObject::connect(add, &QPushButton::clicked, [this]() { - table->setRowCount(table->rowCount() + 1); - table->setItem(table->rowCount() - 1, 0, new QTableWidgetItem); - table->setItem(table->rowCount() - 1, 1, new QTableWidgetItem); - }); - QObject::connect(remove, &QPushButton::clicked, [this]() { table->removeRow(table->currentRow()); }); - QObject::connect(table, &QTableWidget::itemSelectionChanged, [=]() { - remove->setEnabled(table->currentRow() != -1); - }); -} - -void ValueDescriptionDlg::save() { - for (int i = 0; i < table->rowCount(); ++i) { - 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.toStdString()}); - } - } - QDialog::accept(); -} - -QWidget *ValueDescriptionDlg::Delegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const { - QLineEdit *edit = new QLineEdit(parent); - edit->setFrame(false); - if (index.column() == 0) { - edit->setValidator(new DoubleValidator(parent)); - } - return edit; -} diff --git a/openpilot/tools/cabana/signalview.h b/openpilot/tools/cabana/signalview.h deleted file mode 100644 index e2a99ebbd1..0000000000 --- a/openpilot/tools/cabana/signalview.h +++ /dev/null @@ -1,155 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/chart/chartswidget.h" -#include "tools/cabana/chart/sparkline.h" - -class SignalModel : public QAbstractItemModel { - Q_OBJECT -public: - struct Item { - enum Type {Root, Sig, Name, Size, Node, Endian, Signed, Offset, Factor, SignalType, MultiplexValue, ExtraInfo, Unit, Comment, Min, Max, Desc }; - ~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; - std::vector children; - - const cabana::Signal *sig = nullptr; - QString title; - bool highlight = false; - QString sig_val = "-"; - Sparkline sparkline; - }; - - SignalModel(QObject *parent); - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 2; } - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override; - QModelIndex parent(const QModelIndex &index) const override; - Qt::ItemFlags flags(const QModelIndex &index) const override; - bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; - void setMessage(const MessageId &id); - void setFilter(const QString &txt); - bool saveSignal(const cabana::Signal *origin_s, cabana::Signal &s); - Item *getItem(const QModelIndex &index) const; - int signalRow(const cabana::Signal *sig) const; - -private: - void insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig); - void handleSignalAdded(MessageId id, const cabana::Signal *sig); - void handleSignalUpdated(const cabana::Signal *sig); - void handleSignalRemoved(const cabana::Signal *sig); - void handleMsgChanged(MessageId id); - void refresh(); - - MessageId msg_id; - QString filter_str; - std::unique_ptr root; - Connections connections_; - friend class SignalView; - friend class SignalItemDelegate; -}; - -class ValueDescriptionDlg : public QDialog { -public: - ValueDescriptionDlg(const ValueDescription &descriptions, QWidget *parent); - ValueDescription val_desc; - -private: - struct Delegate : public QStyledItemDelegate { - Delegate(QWidget *parent) : QStyledItemDelegate(parent) {} - QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - }; - - void save(); - QTableWidget *table; -}; - -class SignalItemDelegate : public QStyledItemDelegate { -public: - SignalItemDelegate(QObject *parent); - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; - QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override; - - QValidator *name_validator, *double_validator, *node_validator; - QFont label_font, minmax_font; - const int color_label_width = 18; - mutable QSize button_size; -}; - -class SignalView : public QFrame { - Q_OBJECT - -public: - SignalView(ChartsWidget *charts, QWidget *parent); - void setMessage(const MessageId &id); - void signalHovered(const cabana::Signal *sig); - void updateChartState(); - void selectSignal(const cabana::Signal *sig, bool expand = false); - void rowClicked(const QModelIndex &index); - SignalModel *model = nullptr; - -signals: - void highlight(const cabana::Signal *sig); - void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); - -private: - void rowsChanged(); - void resizeEvent(QResizeEvent* event) override; - void updateToolBar(); - void setSparklineRange(int value); - void handleSignalAdded(MessageId id, const cabana::Signal *sig); - void handleSignalUpdated(const cabana::Signal *sig); - void updateState(const std::set *msgs = nullptr); - std::pair visibleSignalRange(); - - struct TreeView : public QTreeView { - TreeView(QWidget *parent) : QTreeView(parent) {} - void rowsInserted(const QModelIndex &parent, int start, int end) override { - ((SignalView *)parentWidget())->rowsChanged(); - // update widget geometries in QTreeView::rowsInserted - QTreeView::rowsInserted(parent, start, end); - } - void setModel(QAbstractItemModel *m) override { - QTreeView::setModel(m); - // Bypass the slow call to QTreeView::dataChanged. - 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); - QTreeView::leaveEvent(event); - } - }; - int max_value_width = 0; - int value_column_width = 0; - TreeView *tree; - QLabel *sparkline_label; - QSlider *sparkline_range_slider; - QLineEdit *filter_edit; - ChartsWidget *charts; - QLabel *signal_count_lb; - SignalItemDelegate *delegate; - Connections connections_; -}; diff --git a/openpilot/tools/cabana/streamselector.cc b/openpilot/tools/cabana/streamselector.cc deleted file mode 100644 index 31532737f3..0000000000 --- a/openpilot/tools/cabana/streamselector.cc +++ /dev/null @@ -1,330 +0,0 @@ -#include "tools/cabana/streamselector.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/streams/devicestream.h" -#include "tools/cabana/streams/replaystream.h" -#include "tools/cabana/routesdialog.h" -#include "tools/cabana/utils/qtutil.h" - -// OpenReplayWidget - -OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QGridLayout *grid_layout = new QGridLayout(this); - grid_layout->addWidget(new QLabel(tr("Route")), 0, 0); - grid_layout->addWidget(route_edit = new QLineEdit(this), 0, 1); - route_edit->setPlaceholderText(tr("Enter route name or browse for local/remote route")); - auto browse_remote_btn = new QPushButton(tr("Remote route..."), this); - grid_layout->addWidget(browse_remote_btn, 0, 2); - auto browse_local_btn = new QPushButton(tr("Local route..."), this); - grid_layout->addWidget(browse_local_btn, 0, 3); - - QHBoxLayout *camera_layout = new QHBoxLayout(); - for (auto c : {tr("Road camera"), tr("Driver camera"), tr("Wide road camera")}) - camera_layout->addWidget(cameras.emplace_back(new QCheckBox(c, this))); - cameras[0]->setChecked(true); - camera_layout->addStretch(1); - grid_layout->addItem(camera_layout, 1, 1); - - setMinimumWidth(550); - QObject::connect(browse_local_btn, &QPushButton::clicked, [=]() { - 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 = std::filesystem::absolute(dir.toStdString()).parent_path().string(); - } - }); - QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { - RoutesDialog route_dlg(this); - if (route_dlg.exec()) { - route_edit->setText(QString::fromStdString(route_dlg.route())); - } - }); -} - -AbstractStream *OpenReplayWidget::open() { - QString route = route_edit->text(); - QString data_dir; - if (int idx = route.lastIndexOf('/'); idx != -1 && util::file_exists(route.toStdString())) { - data_dir = route.mid(0, idx + 1); - route = route.mid(idx + 1); - } - - bool is_valid_format = Route::parseRoute(route.toStdString()).str.size() > 0; - if (!is_valid_format) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Invalid route format: '%1'").arg(route)); - } else { - auto replay_stream = std::make_unique(); - Connection err = replay_stream->error.connect([](const std::string &msg) { - QMessageBox::warning(nullptr, tr("Error"), QString::fromStdString(msg)); - }); - uint32_t flags = REPLAY_FLAG_NONE; - if (cameras[1]->isChecked()) flags |= REPLAY_FLAG_CABIN_CAMERA; - if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_WIDE_ROAD; - if (flags == REPLAY_FLAG_NONE && !cameras[0]->isChecked()) flags = REPLAY_FLAG_NO_VIPC; - - if (replay_stream->loadRoute(route.toStdString(), data_dir.toStdString(), flags)) { - return replay_stream.release(); - } - } - return nullptr; -} - -// OpenPandaWidget - -static const uint32_t speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U}; -static const uint32_t data_speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U, 2000U, 5000U}; - -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(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; - } - - QHBoxLayout *serial_layout = new QHBoxLayout(); - serial_layout->addWidget(serial_edit = new QComboBox()); - - QPushButton *refresh = new QPushButton(tr("Refresh")); - refresh->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - serial_layout->addWidget(refresh); - form_layout->addRow(tr("Serial"), serial_layout); - - QObject::connect(refresh, &QPushButton::clicked, this, &OpenPandaWidget::refreshSerials); - QObject::connect(serial_edit, &QComboBox::currentTextChanged, this, &OpenPandaWidget::buildConfigForm); - - // Populate serials - refreshSerials(); - buildConfigForm(); -} - -void OpenPandaWidget::refreshSerials() { - serial_edit->clear(); - for (auto serial : Panda::list()) { - serial_edit->addItem(QString::fromStdString(serial)); - } -} - -void OpenPandaWidget::buildConfigForm() { - for (int i = form_layout->rowCount() - 1; i > 0; --i) { - form_layout->removeRow(i); - } - - QString serial = serial_edit->currentText(); - bool has_fd = false; - bool has_panda = !serial.isEmpty(); - if (has_panda) { - try { - 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) { - fprintf(stderr, "failed to open panda %s\n", serial.toUtf8().constData()); - has_panda = false; - } - } - - if (has_panda) { - config.serial = serial.toStdString(); - config.bus_config.resize(3); - for (int i = 0; i < config.bus_config.size(); i++) { - QHBoxLayout *bus_layout = new QHBoxLayout; - - // CAN Speed - bus_layout->addWidget(new QLabel(tr("CAN Speed (kbps):"))); - QComboBox *can_speed = new QComboBox; - for (int j = 0; j < std::size(speeds); j++) { - can_speed->addItem(QString::number(speeds[j])); - - if (data_speeds[j] == config.bus_config[i].can_speed_kbps) { - can_speed->setCurrentIndex(j); - } - } - QObject::connect(can_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].can_speed_kbps = speeds[index];}); - bus_layout->addWidget(can_speed); - - // CAN-FD Speed - if (has_fd) { - QCheckBox *enable_fd = new QCheckBox("CAN-FD"); - bus_layout->addWidget(enable_fd); - bus_layout->addWidget(new QLabel(tr("Data Speed (kbps):"))); - QComboBox *data_speed = new QComboBox; - for (int j = 0; j < std::size(data_speeds); j++) { - data_speed->addItem(QString::number(data_speeds[j])); - - if (data_speeds[j] == config.bus_config[i].data_speed_kbps) { - data_speed->setCurrentIndex(j); - } - } - - data_speed->setEnabled(false); - bus_layout->addWidget(data_speed); - - QObject::connect(data_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].data_speed_kbps = data_speeds[index];}); - QObject::connect(enable_fd, &QCheckBox::stateChanged, data_speed, &QComboBox::setEnabled); - QObject::connect(enable_fd, &QCheckBox::stateChanged, [=](int state) {config.bus_config[i].can_fd = (bool)state;}); - } - - form_layout->addRow(tr("Bus %1:").arg(i), bus_layout); - } - } else { - config.serial = ""; - form_layout->addWidget(new QLabel(tr("No panda found"))); - } -} - -AbstractStream *OpenPandaWidget::open() { - try { - return new PandaStream(config); - } catch (std::exception &e) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to panda: '%1'").arg(e.what())); - return nullptr; - } -} - -// OpenDeviceWidget - -OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QRadioButton *msgq = new QRadioButton(tr("MSGQ")); - QRadioButton *zmq = new QRadioButton(tr("ZMQ")); - ip_address = new QLineEdit(this); - ip_address->setPlaceholderText(tr("Enter device Ip Address")); - ip_address->setValidator(new IpAddressValidator(this)); - - group = new QButtonGroup(this); - group->addButton(msgq, 0); - group->addButton(zmq, 1); - - QFormLayout *form_layout = new QFormLayout(this); - form_layout->addRow(msgq); - form_layout->addRow(zmq, ip_address); - QObject::connect(group, qOverload(&QButtonGroup::buttonToggled), [=](QAbstractButton *button, bool checked) { - ip_address->setEnabled(button == zmq && checked); - }); - zmq->setChecked(true); -} - -AbstractStream *OpenDeviceWidget::open() { - std::string ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text().toStdString(); - bool msgq = group->checkedId() == 0; - return new DeviceStream(msgq ? "" : ip); -} - -#ifdef __linux__ -// OpenSocketCanWidget - -OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->addStretch(1); - - QFormLayout *form_layout = new QFormLayout(); - - QHBoxLayout *device_layout = new QHBoxLayout(); - device_edit = new QComboBox(); - device_edit->setFixedWidth(300); - device_layout->addWidget(device_edit); - - QPushButton *refresh = new QPushButton(tr("Refresh")); - refresh->setFixedWidth(100); - device_layout->addWidget(refresh); - form_layout->addRow(tr("Device"), device_layout); - main_layout->addLayout(form_layout); - - main_layout->addStretch(1); - - QObject::connect(refresh, &QPushButton::clicked, this, &OpenSocketCanWidget::refreshDevices); - QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText().toStdString(); }); - - // Populate devices - refreshDevices(); -} - -void OpenSocketCanWidget::refreshDevices() { - device_edit->clear(); - // 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(config); - } catch (std::exception &e) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to SocketCAN device: '%1'").arg(e.what())); - return nullptr; - } -} -#endif - -// StreamSelector - -StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { - setWindowTitle(tr("Open stream")); - QVBoxLayout *layout = new QVBoxLayout(this); - tab = new QTabWidget(this); - layout->addWidget(tab); - - QHBoxLayout *dbc_layout = new QHBoxLayout(); - dbc_file = new QLineEdit(this); - dbc_file->setReadOnly(true); - dbc_file->setPlaceholderText(tr("Choose a dbc file to open")); - QPushButton *file_btn = new QPushButton(tr("Browse...")); - dbc_layout->addWidget(new QLabel(tr("dbc File"))); - dbc_layout->addWidget(dbc_file); - dbc_layout->addWidget(file_btn); - layout->addLayout(dbc_layout); - - QFrame *line = new QFrame(this); - line->setFrameStyle(QFrame::HLine | QFrame::Sunken); - layout->addWidget(line); - - btn_box = new QDialogButtonBox(QDialogButtonBox::Open | QDialogButtonBox::Cancel); - layout->addWidget(btn_box); - - 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); - QObject::connect(btn_box, &QDialogButtonBox::accepted, [=]() { - setEnabled(false); - if (stream_ = ((AbstractOpenStreamWidget *)tab->currentWidget())->open(); stream_) { - accept(); - } - setEnabled(true); - }); - QObject::connect(file_btn, &QPushButton::clicked, [this]() { - 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 = std::filesystem::absolute(fn.toStdString()).parent_path().string(); - } - }); -} - -void StreamSelector::addStreamWidget(AbstractOpenStreamWidget *w, const QString &title) { - tab->addTab(w, title); - auto open_btn = btn_box->button(QDialogButtonBox::Open); - QObject::connect(w, &AbstractOpenStreamWidget::enableOpenButton, open_btn, &QPushButton::setEnabled); -} diff --git a/openpilot/tools/cabana/streamselector.h b/openpilot/tools/cabana/streamselector.h deleted file mode 100644 index 1210806a3b..0000000000 --- a/openpilot/tools/cabana/streamselector.h +++ /dev/null @@ -1,99 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/streams/pandastream.h" -#ifdef __linux__ -#include "tools/cabana/streams/socketcanstream.h" -#endif - -class AbstractOpenStreamWidget : public QWidget { - Q_OBJECT -public: - AbstractOpenStreamWidget(QWidget *parent = nullptr) : QWidget(parent) {} - virtual AbstractStream *open() = 0; - -signals: - void enableOpenButton(bool); -}; - -class OpenReplayWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenReplayWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - QLineEdit *route_edit; - std::vector cameras; -}; - -class OpenPandaWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenPandaWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - void refreshSerials(); - void buildConfigForm(); - - QComboBox *serial_edit; - QFormLayout *form_layout; - PandaStreamConfig config = {}; -}; - -class OpenDeviceWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenDeviceWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - QLineEdit *ip_address; - QButtonGroup *group; -}; - -#ifdef __linux__ -// no Q_OBJECT: moc does not define __linux__ and would otherwise skip this class -class OpenSocketCanWidget : public AbstractOpenStreamWidget { -public: - OpenSocketCanWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - void refreshDevices(); - - QComboBox *device_edit; - SocketCanStreamConfig config = {}; -}; -#endif - -class StreamSelector : public QDialog { - Q_OBJECT - -public: - StreamSelector(QWidget *parent = nullptr); - void addStreamWidget(AbstractOpenStreamWidget *w, const QString &title); - QString dbcFile() const { return dbc_file->text(); } - AbstractStream *stream() const { return stream_; } - -private: - AbstractStream *stream_ = nullptr; - QLineEdit *dbc_file; - QTabWidget *tab; - QDialogButtonBox *btn_box; -}; diff --git a/openpilot/tools/cabana/tools/findsignal.cc b/openpilot/tools/cabana/tools/findsignal.cc deleted file mode 100644 index 2e533741cc..0000000000 --- a/openpilot/tools/cabana/tools/findsignal.cc +++ /dev/null @@ -1,286 +0,0 @@ -#include "tools/cabana/tools/findsignal.h" - -#include -#include - -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/utils/qtutil.h" - -// FindSignalModel - -QVariant FindSignalModel::headerData(int section, Qt::Orientation orientation, int role) const { - static QString titles[] = {"Id", "Start Bit, size", "(time, value)"}; - if (role != Qt::DisplayRole) return {}; - return orientation == Qt::Horizontal ? titles[section] : QString::number(section + 1); -} - -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 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(" "); - } - } - return {}; -} - -void FindSignalModel::search(std::function cmp) { - beginResetModel(); - - std::mutex lock; - const auto prev_sigs = !histories.empty() ? histories.back() : initial_signals; - filtered_signals.clear(); - filtered_signals.reserve(prev_sigs.size()); - - 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.empty()) { - beginResetModel(); - histories.pop_back(); - filtered_signals.clear(); - if (!histories.empty()) filtered_signals = histories.back(); - endResetModel(); - } -} - -void FindSignalModel::reset() { - beginResetModel(); - histories.clear(); - filtered_signals.clear(); - initial_signals.clear(); - endResetModel(); -} - -// FindSignalDlg -FindSignalDlg::FindSignalDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags() | Qt::Window) { - setWindowTitle(tr("Find Signal")); - setAttribute(Qt::WA_DeleteOnClose); - QVBoxLayout *main_layout = new QVBoxLayout(this); - - // Messages group - 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-separated values. Leave blank for all")); - message_layout->addRow(tr("Address"), address_edit = new QLineEdit()); - 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("-")); - hlayout->addWidget(last_time_edit = new QLineEdit("MAX")); - hlayout->addWidget(new QLabel("seconds")); - hlayout->addStretch(0); - message_layout->addRow(tr("Time"), hlayout); - - // Signal group - properties_group = new QGroupBox(tr("Signal")); - QFormLayout *property_layout = new QFormLayout(properties_group); - property_layout->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint); - - hlayout = new QHBoxLayout(); - hlayout->addWidget(min_size = new QSpinBox); - hlayout->addWidget(new QLabel("-")); - hlayout->addWidget(max_size = new QSpinBox); - hlayout->addWidget(litter_endian = new QCheckBox(tr("Little endian"))); - hlayout->addWidget(is_signed = new QCheckBox(tr("Signed"))); - hlayout->addStretch(0); - min_size->setRange(1, 64); - max_size->setRange(1, 64); - min_size->setValue(8); - max_size->setValue(8); - litter_endian->setChecked(true); - property_layout->addRow(tr("Size"), hlayout); - property_layout->addRow(tr("Factor"), factor_edit = new QLineEdit("1.0")); - property_layout->addRow(tr("Offset"), offset_edit = new QLineEdit("0.0")); - - // find group - QGroupBox *find_group = new QGroupBox(tr("Find signal"), this); - QVBoxLayout *vlayout = new QVBoxLayout(find_group); - hlayout = new QHBoxLayout(); - hlayout->addWidget(new QLabel(tr("Value"))); - hlayout->addWidget(compare_cb = new QComboBox(this)); - hlayout->addWidget(value1 = new QLineEdit); - hlayout->addWidget(to_label = new QLabel("-")); - hlayout->addWidget(value2 = new QLineEdit); - hlayout->addWidget(undo_btn = new QPushButton(tr("Undo prev find"), this)); - hlayout->addWidget(search_btn = new QPushButton(tr("Find"))); - hlayout->addWidget(reset_btn = new QPushButton(tr("Reset"), this)); - vlayout->addLayout(hlayout); - - compare_cb->addItems({"=", ">", ">=", "!=", "<", "<=", "between"}); - value1->setFocus(Qt::OtherFocusReason); - value2->setVisible(false); - to_label->setVisible(false); - undo_btn->setEnabled(false); - reset_btn->setEnabled(false); - - auto double_validator = new DoubleValidator(this); - for (auto edit : {value1, value2, factor_edit, offset_edit, first_time_edit, last_time_edit}) { - edit->setValidator(double_validator); - } - - vlayout->addWidget(view = new QTableView(this)); - view->setContextMenuPolicy(Qt::CustomContextMenu); - view->horizontalHeader()->setStretchLastSection(true); - view->horizontalHeader()->setSelectionMode(QAbstractItemView::NoSelection); - view->setSelectionBehavior(QAbstractItemView::SelectRows); - view->setModel(model = new FindSignalModel(this)); - - hlayout = new QHBoxLayout(); - hlayout->addWidget(message_group); - hlayout->addWidget(properties_group); - main_layout->addLayout(hlayout); - main_layout->addWidget(find_group); - main_layout->addWidget(stats_label = new QLabel()); - - setMinimumSize({700, 650}); - QObject::connect(search_btn, &QPushButton::clicked, this, &FindSignalDlg::search); - QObject::connect(undo_btn, &QPushButton::clicked, model, &FindSignalModel::undo); - QObject::connect(model, &QAbstractItemModel::modelReset, this, &FindSignalDlg::modelReset); - QObject::connect(reset_btn, &QPushButton::clicked, model, &FindSignalModel::reset); - QObject::connect(view, &QTableView::customContextMenuRequested, this, &FindSignalDlg::customMenuRequested); - QObject::connect(view, &QTableView::doubleClicked, [this](const QModelIndex &index) { - if (index.isValid()) emit openMessage(model->filtered_signals[index.row()].id); - }); - QObject::connect(compare_cb, qOverload(&QComboBox::currentIndexChanged), [=](int index) { - to_label->setVisible(index == compare_cb->count() - 1); - value2->setVisible(index == compare_cb->count() - 1); - }); -} - -void FindSignalDlg::search() { - if (model->histories.empty()) { - setInitialSignals(); - } - auto v1 = value1->text().toDouble(); - auto v2 = value2->text().toDouble(); - std::function cmp = nullptr; - switch (compare_cb->currentIndex()) { - case 0: cmp = [v1](double v) { return v == v1;}; break; - case 1: cmp = [v1](double v) { return v > v1;}; break; - case 2: cmp = [v1](double v) { return v >= v1;}; break; - case 3: cmp = [v1](double v) { return v != v1;}; break; - case 4: cmp = [v1](double v) { return v < v1;}; break; - case 5: cmp = [v1](double v) { return v <= v1;}; break; - case 6: cmp = [v1, v2](double v) { return v >= v1 && v <= v2;}; break; - } - properties_group->setEnabled(false); - message_group->setEnabled(false); - search_btn->setEnabled(false); - stats_label->setVisible(false); - search_btn->setText("Finding ...."); - QTimer::singleShot(0, this, [=]() { model->search(cmp); }); -} - -void FindSignalDlg::setInitialSignals() { - std::set buses; - for (auto bus : bus_edit->text().trimmed().split(",")) { - bus = bus.trimmed(); - if (!bus.isEmpty()) buses.insert(bus.toUShort()); - } - - std::set addresses; - for (auto addr : address_edit->text().trimmed().split(",")) { - addr = addr.trimmed(); - if (!addr.isEmpty()) addresses.insert(addr.toULong(nullptr, 16)); - } - - cabana::Signal sig{}; - sig.is_little_endian = litter_endian->isChecked(); - sig.is_signed = is_signed->isChecked(); - sig.factor = factor_edit->text().toDouble(); - sig.offset = offset_edit->text().toDouble(); - - double first_time_val = first_time_edit->text().toDouble(); - double last_time_val = last_time_edit->text().toDouble(); - auto [first_sec, last_sec] = std::minmax(first_time_val, last_time_val); - uint64_t first_time = can->toMonoTime(first_sec); - model->last_time = std::numeric_limits::max(); - if (last_sec > 0) { - model->last_time = can->toMonoTime(last_sec); - } - model->initial_signals.clear(); - - for (const auto &[id, m] : can->lastMessages()) { - 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()) { - const int total_size = m.dat.size() * 8; - for (int size = min_size->value(); size <= max_size->value(); ++size) { - for (int start = 0; start <= total_size - size; ++start) { - FindSignalModel::SearchSignal s{.id = id, .mono_time = first_time, .sig = sig}; - s.sig.start_bit = start; - s.sig.size = size; - updateMsbLsb(s.sig); - s.value = get_raw_value((*e)->dat, (*e)->size, s.sig); - model->initial_signals.push_back(s); - } - } - } - } - } -} - -void FindSignalDlg::modelReset() { - 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.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())); -} - -void FindSignalDlg::customMenuRequested(const QPoint &pos) { - if (auto index = view->indexAt(pos); index.isValid()) { - QMenu menu(this); - menu.addAction(tr("Create Signal")); - if (menu.exec(view->mapToGlobal(pos))) { - auto &s = model->filtered_signals[index.row()]; - UndoStack::instance()->push(new AddSigCommand(s.id, s.sig)); - emit openMessage(s.id); - } - } -} diff --git a/openpilot/tools/cabana/tools/findsignal.h b/openpilot/tools/cabana/tools/findsignal.h deleted file mode 100644 index b7cae73b49..0000000000 --- a/openpilot/tools/cabana/tools/findsignal.h +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/settings.h" - -class FindSignalModel : public QAbstractTableModel { -public: - struct SearchSignal { - MessageId id = {}; - uint64_t mono_time = 0; - cabana::Signal sig = {}; - double value = 0.; - QStringList values; - }; - - FindSignalModel(QObject *parent) : QAbstractTableModel(parent) {} - 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((int)filtered_signals.size(), 300); } - void search(std::function cmp); - void reset(); - void undo(); - - std::vector filtered_signals; - std::vector initial_signals; - std::vector> histories; - uint64_t last_time = std::numeric_limits::max(); -}; - -class FindSignalDlg : public QDialog { - Q_OBJECT -public: - FindSignalDlg(QWidget *parent); - -signals: - void openMessage(const MessageId &id); - -private: - void search(); - void modelReset(); - void setInitialSignals(); - void customMenuRequested(const QPoint &pos); - - QLineEdit *value1, *value2, *factor_edit, *offset_edit; - QLineEdit *bus_edit, *address_edit, *first_time_edit, *last_time_edit; - QComboBox *compare_cb; - QSpinBox *min_size, *max_size; - QCheckBox *litter_endian, *is_signed; - QPushButton *search_btn, *reset_btn, *undo_btn; - QGroupBox *properties_group, *message_group; - QTableView *view; - QLabel *to_label, *stats_label; - FindSignalModel *model; -}; diff --git a/openpilot/tools/cabana/tools/findsimilarbits.cc b/openpilot/tools/cabana/tools/findsimilarbits.cc deleted file mode 100644 index 8062b61199..0000000000 --- a/openpilot/tools/cabana/tools/findsimilarbits.cc +++ /dev/null @@ -1,161 +0,0 @@ -#include "tools/cabana/tools/findsimilarbits.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" - -FindSimilarBitsDlg::FindSimilarBitsDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags() | Qt::Window) { - setWindowTitle(tr("Find similar bits")); - setAttribute(Qt::WA_DeleteOnClose); - - QVBoxLayout *main_layout = new QVBoxLayout(this); - - QHBoxLayout *src_layout = new QHBoxLayout(); - src_bus_combo = new QComboBox(this); - find_bus_combo = new QComboBox(this); - for (auto cb : {src_bus_combo, find_bus_combo}) { - for (uint8_t bus : can->sources) { - cb->addItem(QString::number(bus), bus); - } - } - - msg_cb = new QComboBox(this); - // TODO: update when src_bus_combo changes - for (auto &[address, msg] : dbc()->getMessages(-1)) { - msg_cb->addItem(QString::fromStdString(msg.name), address); - } - msg_cb->model()->sort(0); - msg_cb->setCurrentIndex(0); - - byte_idx_sb = new QSpinBox(this); - byte_idx_sb->setFixedWidth(50); - byte_idx_sb->setRange(0, 63); - - bit_idx_sb = new QSpinBox(this); - bit_idx_sb->setFixedWidth(50); - bit_idx_sb->setRange(0, 7); - - src_layout->addWidget(new QLabel(tr("Bus"))); - src_layout->addWidget(src_bus_combo); - src_layout->addWidget(msg_cb); - src_layout->addWidget(new QLabel(tr("Byte Index"))); - src_layout->addWidget(byte_idx_sb); - src_layout->addWidget(new QLabel(tr("Bit Index"))); - src_layout->addWidget(bit_idx_sb); - src_layout->addStretch(0); - - QHBoxLayout *find_layout = new QHBoxLayout(); - find_layout->addWidget(new QLabel(tr("Bus"))); - find_layout->addWidget(find_bus_combo); - find_layout->addWidget(new QLabel(tr("Equal"))); - equal_combo = new QComboBox(this); - equal_combo->addItems({"Yes", "No"}); - find_layout->addWidget(equal_combo); - min_msgs = new QLineEdit(this); - min_msgs->setValidator(new QIntValidator(this)); - min_msgs->setText("100"); - find_layout->addWidget(new QLabel(tr("Min msg count"))); - find_layout->addWidget(min_msgs); - search_btn = new QPushButton(tr("&Find"), this); - find_layout->addWidget(search_btn); - find_layout->addStretch(0); - - QGridLayout *grid_layout = new QGridLayout(); - grid_layout->addWidget(new QLabel("Find From:"), 0, 0); - grid_layout->addLayout(src_layout, 0, 1); - grid_layout->addWidget(new QLabel("Find In:"), 1, 0); - grid_layout->addLayout(find_layout, 1, 1); - main_layout->addLayout(grid_layout); - - table = new QTableWidget(this); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setEditTriggers(QAbstractItemView::NoEditTriggers); - table->horizontalHeader()->setStretchLastSection(true); - main_layout->addWidget(table); - - setMinimumSize({700, 500}); - QObject::connect(search_btn, &QPushButton::clicked, this, &FindSimilarBitsDlg::find); - QObject::connect(table, &QTableWidget::doubleClicked, [this](const QModelIndex &index) { - if (index.isValid()) { - MessageId msg_id = {.source = (uint8_t)find_bus_combo->currentData().toUInt(), .address = table->item(index.row(), 0)->text().toUInt(0, 16)}; - emit openMessage(msg_id); - } - }); -} - -void FindSimilarBitsDlg::find() { - search_btn->setEnabled(false); - table->clear(); - uint32_t selected_address = msg_cb->currentData().toUInt(); - auto msg_mismatched = calcBits(src_bus_combo->currentText().toUInt(), selected_address, byte_idx_sb->value(), bit_idx_sb->value(), - find_bus_combo->currentText().toUInt(), equal_combo->currentIndex() == 0, min_msgs->text().toInt()); - table->setRowCount(msg_mismatched.size()); - table->setColumnCount(6); - table->setHorizontalHeaderLabels({"address", "byte idx", "bit idx", "mismatches", "total msgs", "% mismatched"}); - for (int i = 0; i < msg_mismatched.size(); ++i) { - auto &m = msg_mismatched[i]; - table->setItem(i, 0, new QTableWidgetItem(QString("%1").arg(m.address, 1, 16))); - table->setItem(i, 1, new QTableWidgetItem(QString::number(m.byte_idx))); - table->setItem(i, 2, new QTableWidgetItem(QString::number(m.bit_idx))); - table->setItem(i, 3, new QTableWidgetItem(QString::number(m.mismatches))); - table->setItem(i, 4, new QTableWidgetItem(QString::number(m.total))); - table->setItem(i, 5, new QTableWidgetItem(QString::number(m.perc, 'f', 2))); - } - search_btn->setEnabled(true); -} - -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) { - if (e->src == bus) { - if (e->address == selected_address && e->size > byte_idx) { - bit_to_find = ((e->dat[byte_idx] >> (7 - bit_idx)) & 1) != 0; - } - } - if (e->src == find_bus) { - ++msg_count[e->address]; - if (bit_to_find == -1) continue; - - auto &mismatched = mismatches[e->address]; - if (mismatched.size() < e->size * 8) { - mismatched.resize(e->size * 8); - } - for (int i = 0; i < e->size; ++i) { - for (int j = 0; j < 8; ++j) { - int bit = ((e->dat[i] >> (7 - j)) & 1) != 0; - mismatched[i * 8 + j] += equal ? (bit != bit_to_find) : (bit == bit_to_find); - } - } - } - } - - std::vector result; - result.reserve(mismatches.size()); - for (auto it = mismatches.begin(); it != mismatches.end(); ++it) { - 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->first, (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); - } - } - } - } - std::sort(result.begin(), result.end(), [](auto &l, auto &r) { return l.perc < r.perc; }); - return result; -} diff --git a/openpilot/tools/cabana/tools/findsimilarbits.h b/openpilot/tools/cabana/tools/findsimilarbits.h deleted file mode 100644 index 3451360654..0000000000 --- a/openpilot/tools/cabana/tools/findsimilarbits.h +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" - -class FindSimilarBitsDlg : public QDialog { - Q_OBJECT - -public: - FindSimilarBitsDlg(QWidget *parent); - -signals: - void openMessage(const MessageId &msg_id); - -private: - struct mismatched_struct { - uint32_t address, byte_idx, bit_idx, mismatches, total; - float perc; - }; - 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(); - - QTableWidget *table; - QComboBox *src_bus_combo, *find_bus_combo, *msg_cb, *equal_combo; - QSpinBox *byte_idx_sb, *bit_idx_sb; - QPushButton *search_btn; - QLineEdit *min_msgs; -}; diff --git a/openpilot/tools/cabana/tools/routeinfo.cc b/openpilot/tools/cabana/tools/routeinfo.cc deleted file mode 100644 index 1037d4a206..0000000000 --- a/openpilot/tools/cabana/tools/routeinfo.cc +++ /dev/null @@ -1,40 +0,0 @@ -#include "tools/cabana/tools/routeinfo.h" -#include -#include -#include -#include -#include "tools/cabana/streams/replaystream.h" - -RouteInfoDlg::RouteInfoDlg(QWidget *parent) : QDialog(parent) { - auto *replay = dynamic_cast(can)->getReplay(); - setWindowTitle(tr("Route: %1").arg(QString::fromStdString(replay->route().name()))); - - auto *table = new QTableWidget(replay->route().segments().size(), 7, this); - table->setToolTip(tr("Click on a row to seek to the corresponding segment.")); - table->setEditTriggers(QAbstractItemView::NoEditTriggers); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setHorizontalHeaderLabels({"", "rlog", "narrow road", "wide road", "driver", "qlog", "qcam"}); - table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); - table->verticalHeader()->setVisible(false); - table->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - int row = 0; - for (const auto &[seg_num, seg] : replay->route().segments()) { - table->setItem(row, 0, new QTableWidgetItem(QString::number(seg_num))); - table->setItem(row, 1, new QTableWidgetItem(seg.rlog.empty() ? "--" : "Yes")); - table->setItem(row, 2, new QTableWidgetItem(seg.narrow_road_cam.empty() ? "--" : "Yes")); - table->setItem(row, 3, new QTableWidgetItem(seg.wide_road_cam.empty() ? "--" : "Yes")); - table->setItem(row, 4, new QTableWidgetItem(seg.cabin_cam.empty() ? "--" : "Yes")); - table->setItem(row, 5, new QTableWidgetItem(seg.qlog.empty() ? "--" : "Yes")); - table->setItem(row, 6, new QTableWidgetItem(seg.qcamera.empty() ? "--" : "Yes")); - ++row; - } - table->setMinimumWidth(table->horizontalHeader()->length() + table->verticalScrollBar()->sizeHint().width()); - table->setMinimumHeight(table->rowHeight(0) * std::min(table->rowCount(), 13) + table->horizontalHeader()->height() + table->frameWidth() * 2); - - connect(table, &QTableWidget::itemClicked, [](QTableWidgetItem *item) { can->seekTo(item->row() * 60.0); }); - - QVBoxLayout *layout = new QVBoxLayout(this); - layout->addWidget(table); -} diff --git a/openpilot/tools/cabana/tools/routeinfo.h b/openpilot/tools/cabana/tools/routeinfo.h deleted file mode 100644 index 36f32b4bf4..0000000000 --- a/openpilot/tools/cabana/tools/routeinfo.h +++ /dev/null @@ -1,8 +0,0 @@ -#pragma once -#include - -class RouteInfoDlg : public QDialog { - Q_OBJECT -public: - RouteInfoDlg(QWidget *parent = nullptr); -}; diff --git a/openpilot/tools/cabana/utils/elidedlabel.cc b/openpilot/tools/cabana/utils/elidedlabel.cc deleted file mode 100644 index 629a108b16..0000000000 --- a/openpilot/tools/cabana/utils/elidedlabel.cc +++ /dev/null @@ -1,29 +0,0 @@ -#include "tools/cabana/utils/elidedlabel.h" -#include -#include - -ElidedLabel::ElidedLabel(QWidget *parent) : ElidedLabel({}, parent) {} - -ElidedLabel::ElidedLabel(const QString &text, QWidget *parent) : QLabel(text.trimmed(), parent) { - setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - setMinimumWidth(1); -} - -void ElidedLabel::resizeEvent(QResizeEvent* event) { - QLabel::resizeEvent(event); - lastText_ = elidedText_ = ""; -} - -void ElidedLabel::paintEvent(QPaintEvent *event) { - const QString curText = text(); - if (curText != lastText_) { - elidedText_ = fontMetrics().elidedText(curText, Qt::ElideRight, contentsRect().width()); - lastText_ = curText; - } - - QPainter painter(this); - drawFrame(&painter); - QStyleOption opt; - opt.initFrom(this); - style()->drawItemText(&painter, contentsRect(), alignment(), opt.palette, isEnabled(), elidedText_, foregroundRole()); -} diff --git a/openpilot/tools/cabana/utils/elidedlabel.h b/openpilot/tools/cabana/utils/elidedlabel.h deleted file mode 100644 index 577eea12a0..0000000000 --- a/openpilot/tools/cabana/utils/elidedlabel.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include -#include - -class ElidedLabel : public QLabel { - Q_OBJECT - -public: - explicit ElidedLabel(QWidget *parent = 0); - explicit ElidedLabel(const QString &text, QWidget *parent = 0); - -signals: - void clicked(); - -protected: - void paintEvent(QPaintEvent *event) override; - void resizeEvent(QResizeEvent* event) override; - void mouseReleaseEvent(QMouseEvent *event) override { - if (rect().contains(event->pos())) { - emit clicked(); - } - } - QString lastText_, elidedText_; -}; diff --git a/openpilot/tools/cabana/utils/qtutil.cc b/openpilot/tools/cabana/utils/qtutil.cc deleted file mode 100644 index 040c90c624..0000000000 --- a/openpilot/tools/cabana/utils/qtutil.cc +++ /dev/null @@ -1,236 +0,0 @@ -#include "tools/cabana/utils/qtutil.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -// MessageBytesDelegate - -MessageBytesDelegate::MessageBytesDelegate(QObject *parent, bool multiple_lines) - : font_metrics(QApplication::font()), multiple_lines(multiple_lines), QStyledItemDelegate(parent) { - fixed_font = QFontDatabase::systemFont(QFontDatabase::FixedFont); - byte_size = QFontMetrics(fixed_font).size(Qt::TextSingleLine, "00 ") + QSize(0, 2); - for (int i = 0; i < 256; ++i) { - hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper()); - hex_text_table[i].prepare({}, fixed_font); - } - h_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1; - v_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameVMargin) + 1; -} - -QSize MessageBytesDelegate::sizeForBytes(int n) const { - int rows = multiple_lines ? std::max(1, n / 8) : 1; - return {(n / rows) * byte_size.width() + h_margin * 2, rows * byte_size.height() + v_margin * 2}; -} - -QSize MessageBytesDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto data = index.data(BytesRole); - return sizeForBytes(data.isValid() ? static_cast *>(data.value())->size() : 0); -} - -void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - if (option.state & QStyle::State_Selected) { - painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight)); - } - - QRect item_rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin); - QColor highlighted_color = option.palette.color(QPalette::HighlightedText); - auto text_color = index.data(Qt::ForegroundRole).value(); - bool inactive = text_color.isValid(); - if (!inactive) { - text_color = option.palette.color(QPalette::Text); - } - auto data = index.data(BytesRole); - if (!data.isValid()) { - painter->setFont(option.font); - painter->setPen(option.state & QStyle::State_Selected ? highlighted_color : text_color); - QString text = font_metrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, item_rect.width()); - painter->drawText(item_rect, Qt::AlignLeft | Qt::AlignVCenter, text); - return; - } - - // Paint hex column - const auto &bytes = *static_cast *>(data.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); - const QPoint pt = item_rect.topLeft(); - for (int i = 0; i < bytes.size(); ++i) { - int row = !multiple_lines ? 0 : i / 8; - int column = !multiple_lines ? i : i % 8; - QRect r({pt.x() + column * byte_size.width(), pt.y() + row * byte_size.height()}, byte_size); - - if (!inactive && i < colors.size() && colors[i].alpha() > 0) { - if (option.state & QStyle::State_Selected) { - painter->setPen(option.palette.color(QPalette::Text)); - painter->fillRect(r, option.palette.color(QPalette::Window)); - } - painter->fillRect(r, toQColor(colors[i])); - } else { - painter->setPen(text_pen); - } - utils::drawStaticText(painter, r, hex_text_table[bytes[i]]); - } -} - -// TabBar - -int TabBar::addTab(const QString &text) { - int index = QTabBar::addTab(text); - QToolButton *btn = new ToolButton("x", tr("Close Tab")); - int width = style()->pixelMetric(QStyle::PM_TabCloseIndicatorWidth, nullptr, btn); - int height = style()->pixelMetric(QStyle::PM_TabCloseIndicatorHeight, nullptr, btn); - btn->setFixedSize({width, height}); - setTabButton(index, QTabBar::RightSide, btn); - QObject::connect(btn, &QToolButton::clicked, this, &TabBar::closeTabClicked); - return index; -} - -void TabBar::closeTabClicked() { - QObject *object = sender(); - for (int i = 0; i < count(); ++i) { - if (tabButton(i, QTabBar::RightSide) == object) { - emit tabCloseRequested(i); - break; - } - } -} - -// validators - -static QValidator::State toQtState(ValidState s) { - switch (s) { - case ValidState::Acceptable: return QValidator::Acceptable; - case ValidState::Intermediate: return QValidator::Intermediate; - default: return QValidator::Invalid; - } -} - -QValidator::State NameValidator::validate(QString &input, int &pos) const { - std::string s = input.toStdString(); - auto state = validateName(s); - input = QString::fromStdString(s); - return toQtState(state); -} - -QValidator::State NodeValidator::validate(QString &input, int &pos) const { - return toQtState(validateNodes(input.toStdString())); -} - -QValidator::State NonWhitespaceValidator::validate(QString &input, int &pos) const { - return toQtState(validateNonWhitespace(input.toStdString())); -} - -QValidator::State IpAddressValidator::validate(QString &input, int &pos) const { - return toQtState(validateIpAddress(input.toStdString())); -} - -QValidator::State DoubleValidator::validate(QString &input, int &pos) const { - return toQtState(validateDouble(input.toLatin1().toStdString())); -} - -namespace utils { - -bool isDarkTheme() { - QColor windowColor = QApplication::palette().color(QPalette::Window); - return windowColor.lightness() < 128; -} - -QPixmap icon(const QString &id) { - bool dark_theme = isDarkTheme(); - - QPixmap pm; - QString key = "bootstrap_" % id % (dark_theme ? "1" : "0"); - if (!QPixmapCache::find(key, &pm)) { - pm = bootstrapPixmap(id); - if (dark_theme) { - QPainter p(&pm); - p.setCompositionMode(QPainter::CompositionMode_SourceIn); - p.fillRect(pm.rect(), QColor("#bbbbbb")); - } - QPixmapCache::insert(key, pm); - } - return pm; -} - -void setTheme(int theme) { - auto style = QApplication::style(); - if (!style) return; - - static int prev_theme = 0; - if (theme != prev_theme) { - prev_theme = theme; - QPalette new_palette; - if (theme == DARK_THEME) { - new_palette.setColor(QPalette::Window, toQColor(DarkTheme::window)); - new_palette.setColor(QPalette::WindowText, toQColor(DarkTheme::window_text)); - new_palette.setColor(QPalette::Base, toQColor(DarkTheme::base)); - new_palette.setColor(QPalette::AlternateBase, toQColor(DarkTheme::base)); - new_palette.setColor(QPalette::ToolTipBase, toQColor(DarkTheme::base)); - new_palette.setColor(QPalette::ToolTipText, toQColor(DarkTheme::tooltip_text)); - new_palette.setColor(QPalette::Text, toQColor(DarkTheme::text)); - new_palette.setColor(QPalette::Button, toQColor(DarkTheme::button)); - new_palette.setColor(QPalette::ButtonText, toQColor(DarkTheme::window_text)); - new_palette.setColor(QPalette::Highlight, toQColor(DarkTheme::highlight)); - new_palette.setColor(QPalette::HighlightedText, toQColor(DarkTheme::window_text)); - new_palette.setColor(QPalette::BrightText, toQColor(DarkTheme::bright_text)); - new_palette.setColor(QPalette::Disabled, QPalette::ButtonText, toQColor(DarkTheme::disabled_text)); - new_palette.setColor(QPalette::Disabled, QPalette::WindowText, toQColor(DarkTheme::disabled_text)); - new_palette.setColor(QPalette::Disabled, QPalette::Text, toQColor(DarkTheme::disabled_text)); - new_palette.setColor(QPalette::Light, toQColor(DarkTheme::light)); - new_palette.setColor(QPalette::Dark, toQColor(DarkTheme::dark)); - } else { - new_palette = style->standardPalette(); - } - qApp->setPalette(new_palette); - style->polish(qApp); - for (auto w : QApplication::allWidgets()) { - w->setPalette(new_palette); - } - } -} - -} // namespace utils - -void sigTermHandler(int s) { - std::signal(s, SIG_DFL); - qApp->quit(); -} - -void initApp(int argc, char *argv[], bool disable_hidpi) { - // setup signal handlers to exit gracefully - std::signal(SIGINT, sigTermHandler); - std::signal(SIGTERM, sigTermHandler); - -#ifdef __APPLE__ - // Get the devicePixelRatio, and scale accordingly to maintain 1:1 rendering - QApplication tmp(argc, argv); - if (disable_hidpi) { - qputenv("QT_SCALE_FACTOR", QString::number(1.0 / tmp.devicePixelRatio()).toLocal8Bit()); - } -#endif - - qputenv("QT_DBL_CLICK_DIST", "150"); - // ensure the current dir matches the exectuable's directory - std::error_code ec; - std::filesystem::current_path(executableDir(), ec); -} - -QPixmap bootstrapPixmap(const QString &id) { - QPixmap pixmap; - const std::string svg = utils::bootstrapSvg(id.toStdString()); - if (!svg.empty()) { - pixmap.loadFromData((const uchar *)svg.data(), svg.size(), "svg"); - } - return pixmap; -} diff --git a/openpilot/tools/cabana/utils/qtutil.h b/openpilot/tools/cabana/utils/qtutil.h deleted file mode 100644 index db0417ec9a..0000000000 --- a/openpilot/tools/cabana/utils/qtutil.h +++ /dev/null @@ -1,139 +0,0 @@ -#pragma once - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/core/observable.h" -#include "tools/cabana/dbc/dbc.h" -#include "tools/cabana/settings.h" -#include "tools/cabana/utils/strings.h" -#include "tools/cabana/utils/util.h" - -// needed by QVariant::fromValue() in the Qt views; goes away with QVariant -Q_DECLARE_METATYPE(MessageId) -Q_DECLARE_METATYPE(ValueDescription) - -inline QColor toQColor(const CabanaColor &color) { - return QColor(color.r, color.g, color.b, color.a); -} - -class LogSlider : public QSlider { - Q_OBJECT - -public: - LogSlider(double factor, Qt::Orientation orientation, QWidget *parent = nullptr) : scale(factor), QSlider(orientation, parent) {} - - void setRange(double min, double max) { - scale.setRange(min, max); - QSlider::setRange(min, max); - setValue(QSlider::value()); - } - int value() const { return scale.value(QSlider::value(), minimum(), maximum()); } - void setValue(int v) { QSlider::setValue(scale.position(v, minimum(), maximum())); } - -private: - LogScale scale; -}; - -enum { - ColorsRole = Qt::UserRole + 1, - BytesRole = Qt::UserRole + 2 -}; - -class MessageBytesDelegate : public QStyledItemDelegate { - Q_OBJECT -public: - MessageBytesDelegate(QObject *parent, bool multiple_lines = false); - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; - bool multipleLines() const { return multiple_lines; } - void setMultipleLines(bool v) { multiple_lines = v; } - QSize sizeForBytes(int n) const; - -private: - std::array hex_text_table; - QFontMetrics font_metrics; - QFont fixed_font; - QSize byte_size = {}; - bool multiple_lines = false; - int h_margin, v_margin; -}; - -// QValidator wrappers around the std::string validators in util.h -#define CABANA_VALIDATOR(Name) \ - class Name : public QValidator { \ - Q_OBJECT \ - public: \ - Name(QObject *parent = nullptr) : QValidator(parent) {} \ - QValidator::State validate(QString &input, int &pos) const override; \ - }; -CABANA_VALIDATOR(NameValidator) -CABANA_VALIDATOR(NodeValidator) -CABANA_VALIDATOR(NonWhitespaceValidator) -CABANA_VALIDATOR(IpAddressValidator) -CABANA_VALIDATOR(DoubleValidator) -#undef CABANA_VALIDATOR - -namespace utils { - -QPixmap icon(const QString &id); -bool isDarkTheme(); -void setTheme(int theme); -inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) { - auto size = (r.size() - text.size()) / 2; - p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text); -} -inline auto qbytes(const std::vector &dat) { - return decltype(QString().toUtf8())((const char *)dat.data(), (int)dat.size()); -} - -} - -class ToolButton : public QToolButton { - Q_OBJECT -public: - ToolButton(const QString &icon, const QString &tooltip = {}, QWidget *parent = nullptr) : QToolButton(parent) { - setIcon(icon); - setToolTip(tooltip); - setAutoRaise(true); - const int metric = QApplication::style()->pixelMetric(QStyle::PM_SmallIconSize); - setIconSize({metric, metric}); - theme = settings.theme; - settings_connection_ = settings.changed.connect([this]() { updateIcon(); }); - } - void setIcon(const QString &icon) { - icon_str = icon; - QToolButton::setIcon(utils::icon(icon_str)); - } - -private: - void updateIcon() { if (std::exchange(theme, settings.theme) != theme) setIcon(icon_str); } - Connection settings_connection_; - QString icon_str; - int theme; -}; - -class TabBar : public QTabBar { - Q_OBJECT - -public: - TabBar(QWidget *parent) : QTabBar(parent) {} - int addTab(const QString &text); - -private: - void closeTabClicked(); -}; - -void initApp(int argc, char *argv[], bool disable_hidpi = true); -QPixmap bootstrapPixmap(const QString &id); diff --git a/openpilot/tools/cabana/videowidget.cc b/openpilot/tools/cabana/videowidget.cc deleted file mode 100644 index f7b1b41628..0000000000 --- a/openpilot/tools/cabana/videowidget.cc +++ /dev/null @@ -1,434 +0,0 @@ -#include "tools/cabana/videowidget.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/tools/routeinfo.h" -#include "tools/cabana/utils/qtutil.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[] = { - QColor(111, 143, 175), - QColor(0, 163, 108), - Qt::green, - QColor(255, 195, 0), - QColor(199, 0, 57), - Qt::magenta, -}; - -static Replay *getReplay() { - auto stream = dynamic_cast(can); - return stream ? stream->getReplay() : nullptr; -} - -VideoWidget::VideoWidget(QWidget *parent) : QFrame(parent) { - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - auto main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - if (!can->liveStreaming()) - main_layout->addWidget(createCameraWidget()); - - createPlaybackController(); - - setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); - connections_.push_back(can->paused.connect([this]() { updatePlayBtnState(); })); - connections_.push_back(can->resume.connect([this]() { updatePlayBtnState(); })); - connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); - connections_.push_back(can->seeking.connect([this](double) { updateState(); })); - connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { timeRangeChanged(); })); - - updatePlayBtnState(); - setWhatsThis(tr(R"( - Video
- - Timeline color - - - - - - - -
■ Disengaged ■ Engaged
■ User Flag ■ Info
■ Warning ■ Critical
- Shortcuts
- Pause/Resume:  space  - )").arg(timeline_colors[(int)TimelineType::None].name(), - timeline_colors[(int)TimelineType::Engaged].name(), - timeline_colors[(int)TimelineType::UserBookmark].name(), - timeline_colors[(int)TimelineType::AlertInfo].name(), - timeline_colors[(int)TimelineType::AlertWarning].name(), - timeline_colors[(int)TimelineType::AlertCritical].name())); -} - -void VideoWidget::createPlaybackController() { - QToolBar *toolbar = new QToolBar(this); - layout()->addWidget(toolbar); - - int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize); - toolbar->setIconSize({icon_size, icon_size}); - - toolbar->addAction(utils::icon("rewind"), tr("Seek backward"), []() { can->seekTo(can->currentSec() - 1); }); - play_toggle_action = toolbar->addAction(utils::icon("play"), tr("Play"), []() { can->pause(!can->isPaused()); }); - toolbar->addAction(utils::icon("fast-forward"), tr("Seek forward"), []() { can->seekTo(can->currentSec() + 1); }); - - if (can->liveStreaming()) { - skip_to_end_action = toolbar->addAction(utils::icon("skip-end"), tr("Skip to the end"), this, [this]() { - // set speed to 1.0 - speed_btn->menu()->actions()[7]->setChecked(true); - can->pause(false); - can->seekTo(can->maxSeconds() + 1); - }); - } - - time_display_action = toolbar->addAction("", this, [this]() { - settings.absolute_time = !settings.absolute_time; - time_display_action->setToolTip(settings.absolute_time ? tr("Elapsed time") : tr("Absolute time")); - updateState(); - }); - - QWidget *spacer = new QWidget(); - spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - toolbar->addWidget(spacer); - - if (!can->liveStreaming()) { - toolbar->addAction(utils::icon("repeat"), tr("Loop playback"), this, &VideoWidget::loopPlaybackClicked); - createSpeedDropdown(toolbar); - toolbar->addSeparator(); - toolbar->addAction(utils::icon("info-circle"), tr("View route details"), this, &VideoWidget::showRouteInfo); - } else { - createSpeedDropdown(toolbar); - } -} - -void VideoWidget::createSpeedDropdown(QToolBar *toolbar) { - toolbar->addWidget(speed_btn = new QToolButton(this)); - speed_btn->setMenu(new QMenu(speed_btn)); - speed_btn->setPopupMode(QToolButton::InstantPopup); - QActionGroup *speed_group = new QActionGroup(this); - speed_group->setExclusive(true); - - for (float speed : {0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.8, 1., 2., 3., 5.}) { - auto act = speed_btn->menu()->addAction(QString("%1x").arg(speed), this, [this, speed]() { - can->setSpeed(speed); - speed_btn->setText(QString("%1x ").arg(speed)); - }); - - speed_group->addAction(act); - act->setCheckable(true); - if (speed == 1.0) { - act->setChecked(true); - act->trigger(); - } - } - - QFont font = speed_btn->font(); - font.setBold(true); - speed_btn->setFont(font); - speed_btn->setMinimumWidth(speed_btn->fontMetrics().horizontalAdvance("0.05x ") + style()->pixelMetric(QStyle::PM_MenuButtonIndicator)); -} - -QWidget *VideoWidget::createCameraWidget() { - QWidget *w = new QWidget(this); - QVBoxLayout *l = new QVBoxLayout(w); - l->setContentsMargins(0, 0, 0, 0); - l->setSpacing(0); - - l->addWidget(camera_tab = new TabBar(w)); - camera_tab->setAutoHide(true); - camera_tab->setExpanding(false); - - l->addWidget(cam_widget = new StreamCameraView("camerad", VISION_STREAM_NARROW_ROAD)); - cam_widget->setMinimumHeight(MIN_VIDEO_HEIGHT); - cam_widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding); - - l->addWidget(slider = new Slider(w)); - slider->setSingleStep(0); - slider->setTimeRange(can->minSeconds(), can->maxSeconds()); - - QObject::connect(slider, &QSlider::sliderReleased, [this]() { can->seekTo(slider->currentSecond()); }); - connections_.push_back(can->paused.connect([this]() { cam_widget->update(); })); - connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &) { slider->update(); })); - connections_.push_back(cam_widget->clicked.connect([]() { can->pause(!can->isPaused()); })); - connections_.push_back(cam_widget->availableStreamsUpdated.connect([this](std::set streams) { vipcAvailableStreamsUpdated(streams); })); - QObject::connect(camera_tab, &QTabBar::currentChanged, [this](int index) { - if (index != -1) cam_widget->setStreamType((VisionStreamType)camera_tab->tabData(index).toInt()); - }); - connections_.push_back(static_cast(can)->qLogLoaded.connect([this](std::shared_ptr qlog) { cam_widget->parseQLog(qlog); })); - slider->installEventFilter(this); - return w; -} - -void VideoWidget::vipcAvailableStreamsUpdated(std::set streams) { - static const QString stream_names[] = {"Road camera", "Driver camera", "Wide road camera"}; - for (int i = 0; i < streams.size(); ++i) { - if (camera_tab->count() <= i) { - camera_tab->addTab(QString()); - } - int type = *std::next(streams.begin(), i); - camera_tab->setTabText(i, stream_names[type]); - camera_tab->setTabData(i, type); - } - while (camera_tab->count() > streams.size()) { - camera_tab->removeTab(camera_tab->count() - 1); - } -} - -void VideoWidget::loopPlaybackClicked() { - bool is_looping = getReplay()->loop(); - getReplay()->setLoop(!is_looping); - qobject_cast(sender())->setIcon(utils::icon(!is_looping ? "repeat" : "repeat-1")); -} - -void VideoWidget::timeRangeChanged() { - const auto time_range = can->timeRange(); - if (can->liveStreaming()) { - skip_to_end_action->setEnabled(!time_range.has_value()); - return; - } - time_range ? slider->setTimeRange(time_range->first, time_range->second) - : slider->setTimeRange(can->minSeconds(), can->maxSeconds()); - updateState(); -} - -QString VideoWidget::formatTime(double sec, bool include_milliseconds) { - if (settings.absolute_time) - sec += std::chrono::duration(can->beginDateTime().time_since_epoch()).count(); - return QString::fromStdString(utils::formatSeconds(sec, include_milliseconds, settings.absolute_time)); -} - -void VideoWidget::updateState() { - if (slider) { - if (!slider->isSliderDown()) { - slider->setCurrentSecond(can->currentSec()); - } - if (camera_tab->count() == 0) { // No streams available - cam_widget->update(); // Manually refresh to show alert events - } - time_display_action->setText(QString("%1 / %2").arg(formatTime(can->currentSec(), true), - formatTime(slider->maximum() / slider->factor))); - } else { - time_display_action->setText(formatTime(can->currentSec(), true)); - } -} - -void VideoWidget::updatePlayBtnState() { - play_toggle_action->setIcon(utils::icon(can->isPaused() ? "play" : "pause")); - play_toggle_action->setToolTip(can->isPaused() ? tr("Play") : tr("Pause")); -} - -void VideoWidget::showThumbnail(double seconds) { - if (can->liveStreaming()) return; - - cam_widget->thumbnail_dispaly_time = seconds; - slider->thumbnail_dispaly_time = seconds; - cam_widget->update(); - slider->update(); -} - -void VideoWidget::showRouteInfo() { - RouteInfoDlg *route_info = new RouteInfoDlg(this); - route_info->setAttribute(Qt::WA_DeleteOnClose); - route_info->show(); -} - -bool VideoWidget::eventFilter(QObject *obj, QEvent *event) { - if (event->type() == QEvent::MouseMove) { - auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds())); - showThumbnail(min_sec + static_cast(event)->pos().x() * (max_sec - min_sec) / slider->width()); - } else if (event->type() == QEvent::Leave) { - showThumbnail(-1); - } - return false; -} - -// Slider -Slider::Slider(QWidget *parent) : QSlider(Qt::Horizontal, parent) { - setMouseTracking(true); -} - -void Slider::paintEvent(QPaintEvent *ev) { - QPainter p(this); - - QStyleOptionSlider opt; - initStyleOption(&opt); - QRect handle_rect = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderHandle, this); - QRect groove_rect = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderGroove, this); - - // Adjust groove height to match handle height - int handle_height = handle_rect.height(); - groove_rect.setHeight(handle_height * 0.5); - groove_rect.moveCenter(QPoint(groove_rect.center().x(), rect().center().y())); - - p.fillRect(groove_rect, timeline_colors[(int)TimelineType::None]); - - double min = minimum() / factor; - double max = maximum() / factor; - - auto fillRange = [&](double begin, double end, const QColor &color) { - if (begin > max || end < min) return; - - QRect r = groove_rect; - r.setLeft(((std::max(min, begin) - min) / (max - min)) * width()); - r.setRight(((std::min(max, end) - min) / (max - min)) * width()); - p.fillRect(r, color); - }; - - if (auto replay = getReplay()) { - for (const auto &entry : *replay->getTimeline()) { - fillRange(entry.start_time, entry.end_time, timeline_colors[(int)entry.type]); - } - - QColor empty_color = palette().color(QPalette::Window); - empty_color.setAlpha(160); - const auto event_data = replay->getEventData(); - for (const auto &[n, _] : replay->route().segments()) { - if (!event_data->isSegmentLoaded(n)) - fillRange(n * 60.0, (n + 1) * 60.0, empty_color); - } - } - - opt.minimum = minimum(); - opt.maximum = maximum(); - opt.subControls = QStyle::SC_SliderHandle; - opt.sliderPosition = value(); - style()->drawComplexControl(QStyle::CC_Slider, &opt, &p); - - if (thumbnail_dispaly_time >= 0) { - int left = (thumbnail_dispaly_time - min) * width() / (max - min) - 1; - QRect rc(left, rect().top() + 1, 2, rect().height() - 2); - p.setBrush(palette().highlight()); - p.setPen(Qt::NoPen); - p.drawRoundedRect(rc, 1.5, 1.5); - } -} - -void Slider::mousePressEvent(QMouseEvent *e) { - QSlider::mousePressEvent(e); - if (e->button() == Qt::LeftButton && !isSliderDown()) { - setValue(minimum() + ((maximum() - minimum()) * e->x()) / width()); - emit sliderReleased(); - } -} - -// StreamCameraView -StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent) - : CameraWidget(stream_name, stream_type, parent) { -} - -void StreamCameraView::parseQLog(std::shared_ptr qlog) { - std::mutex mutex; - 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::paintEvent(QPaintEvent *event) { - CameraWidget::paintEvent(event); - - QPainter p(this); - bool scrubbing = false; - if (thumbnail_dispaly_time >= 0) { - scrubbing = can->isPaused(); - scrubbing ? drawScrubThumbnail(p) : drawThumbnail(p); - } - if (auto alert = getReplay()->findAlertAtTime(scrubbing ? thumbnail_dispaly_time : can->currentSec())) { - drawAlert(p, rect(), *alert); - } - - if (can->isPaused()) { - 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")); - } -} - -QPixmap StreamCameraView::generateThumbnail(QPixmap thumb, double seconds) { - QPixmap scaled = thumb.scaledToHeight(MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2, Qt::SmoothTransformation); - QPainter p(&scaled); - p.setPen(QPen(palette().color(QPalette::BrightText), 2)); - p.drawRect(scaled.rect()); - if (auto alert = getReplay()->findAlertAtTime(seconds)) { - p.setFont(QFont(font().family(), 10)); - drawAlert(p, scaled.rect(), *alert); - } - return scaled; -} - -void StreamCameraView::drawScrubThumbnail(QPainter &p) { - p.fillRect(rect(), Qt::black); - auto it = big_thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time)); - if (it != big_thumbnails.end()) { - 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); - } -} - -void StreamCameraView::drawThumbnail(QPainter &p) { - auto it = thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time)); - if (it != thumbnails.end()) { - 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); - int y = height() - thumb.height() - THUMBNAIL_MARGIN; - - p.drawPixmap(x, y, thumb); - drawTime(p, QRect{x, y, thumb.width(), thumb.height()}, thumbnail_dispaly_time); - } -} - -void StreamCameraView::drawTime(QPainter &p, const QRect &rect, double seconds) { - p.setPen(palette().color(QPalette::BrightText)); - p.setFont(QFont(font().family(), 10)); - p.drawText(rect.adjusted(0, 0, 0, -THUMBNAIL_MARGIN), Qt::AlignHCenter | Qt::AlignBottom, QString::number(seconds, 'f', 3)); -} - -void StreamCameraView::drawAlert(QPainter &p, const QRect &rect, const Timeline::Entry &alert) { - p.setPen(QPen(palette().color(QPalette::BrightText), 2)); - QColor color = timeline_colors[int(alert.type)]; - color.setAlphaF(0.5); - QString text = QString::fromStdString(alert.text1); - if (!alert.text2.empty()) text += "\n" + QString::fromStdString(alert.text2); - - QRect text_rect = rect.adjusted(1, 1, -1, -1); - QRect r = p.fontMetrics().boundingRect(text_rect, Qt::AlignTop | Qt::AlignHCenter | Qt::TextWordWrap, text); - p.fillRect(text_rect.left(), r.top(), text_rect.width(), r.height(), color); - p.drawText(text_rect, Qt::AlignTop | Qt::AlignHCenter | Qt::TextWordWrap, text); -} diff --git a/openpilot/tools/cabana/videowidget.h b/openpilot/tools/cabana/videowidget.h deleted file mode 100644 index 8416eece59..0000000000 --- a/openpilot/tools/cabana/videowidget.h +++ /dev/null @@ -1,82 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -#include "tools/cabana/cameraview.h" -#include "tools/cabana/utils/qtutil.h" -#include "tools/replay/logreader.h" -#include "tools/cabana/streams/replaystream.h" - -class Slider : public QSlider { - Q_OBJECT - -public: - Slider(QWidget *parent); - double currentSecond() const { return value() / factor; } - void setCurrentSecond(double sec) { setValue(sec * factor); } - void setTimeRange(double min, double max) { setRange(min * factor, max * factor); } - void mousePressEvent(QMouseEvent *e) override; - void paintEvent(QPaintEvent *ev) override; - const double factor = 1000.0; - double thumbnail_dispaly_time = -1; -}; - -class StreamCameraView : public CameraWidget { - Q_OBJECT - -public: - StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent = nullptr); - void paintEvent(QPaintEvent *event) override; - void parseQLog(std::shared_ptr qlog); - -private: - QPixmap generateThumbnail(QPixmap thumbnail, double seconds); - void drawAlert(QPainter &p, const QRect &rect, const Timeline::Entry &alert); - void drawThumbnail(QPainter &p); - void drawScrubThumbnail(QPainter &p); - void drawTime(QPainter &p, const QRect &rect, double seconds); - - std::map big_thumbnails; - std::map thumbnails; - double thumbnail_dispaly_time = -1; - friend class VideoWidget; -}; - -class VideoWidget : public QFrame { - Q_OBJECT - -public: - VideoWidget(QWidget *parnet = nullptr); - void showThumbnail(double seconds); - -protected: - bool eventFilter(QObject *obj, QEvent *event) override; - QString formatTime(double sec, bool include_milliseconds = false); - void timeRangeChanged(); - void updateState(); - void updatePlayBtnState(); - Connections connections_; - QWidget *createCameraWidget(); - void createPlaybackController(); - void createSpeedDropdown(QToolBar *toolbar); - void loopPlaybackClicked(); - void vipcAvailableStreamsUpdated(std::set streams); - void showRouteInfo(); - - StreamCameraView *cam_widget; - QAction *time_display_action = nullptr; - QAction *play_toggle_action = nullptr; - QToolButton *speed_btn = nullptr; - QAction *skip_to_end_action = nullptr; - Slider *slider = nullptr; - QTabBar *camera_tab = nullptr; -}; diff --git a/pyproject.toml b/pyproject.toml index c1dd803640..78fb53ed1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "tqdm", # cars (fw_versions.py) on start + many one-off uses # core - "scons==4.10.1", # 4.11 removed the qt3 tool still used to build Cabana + "scons", "pycapnp==2.1.0", # 2.2 introduces a memory leak due to cyclic references "numpy >=2.0", diff --git a/tools/op.sh b/tools/op.sh index 43b28e34c1..3d7d17a76b 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -445,7 +445,7 @@ function op_default() { echo -e "${BOLD}${UNDERLINE}Commands [Tooling]:${NC}" echo -e " ${BOLD}juggle${NC} Run PlotJuggler" echo -e " ${BOLD}replay${NC} Run Replay" - echo -e " ${BOLD}cabana${NC} Run Cabana (--legacy for the old Qt version)" + echo -e " ${BOLD}cabana${NC} Run Cabana" echo -e " ${BOLD}clip${NC} Run clip (linux only)" echo -e " ${BOLD}adb${NC} Run adb shell" echo -e " ${BOLD}ssh${NC} comma prime SSH helper" diff --git a/uv.lock b/uv.lock index 89b82b5452..dc9cb69845 100644 --- a/uv.lock +++ b/uv.lock @@ -675,7 +675,7 @@ requires-dist = [ { name = "rednose", marker = "extra == 'submodules'", editable = "rednose_repo" }, { name = "requests" }, { name = "ruff", marker = "extra == 'testing'" }, - { name = "scons", specifier = "==4.10.1" }, + { name = "scons" }, { name = "sentry-sdk" }, { name = "setproctitle" }, { name = "sounddevice" }, @@ -923,11 +923,11 @@ wheels = [ [[package]] name = "scons" -version = "4.10.1" +version = "4.11.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/c9/2f430bb39e4eccba32ce8008df4a3206df651276422204e177a09e12b30b/scons-4.10.1.tar.gz", hash = "sha256:99c0e94a42a2c1182fa6859b0be697953db07ba936ecc9817ae0d218ced20b15", size = 3258403, upload-time = "2025-11-16T22:43:39.258Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/b9/b7a5c88f348a0c34594d88100872c55fa1cae863ccb222c1c438341b5503/scons-4.11.1.tar.gz", hash = "sha256:4210d1a80a62e986029208117991b6347ccaaaab37b67463a3ff31ee065dc487", size = 3268963, upload-time = "2026-08-27T04:33:18.232Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/43/d6285848e893c19682c06e92679dc1a07d37ff7ea148747b1df681ec496c/scons-4.11.1-py3-none-any.whl", hash = "sha256:454cef364348053422696e3d2ecb4fa593c96a624f955842eaaea64f95c8d11d", size = 4123659, upload-time = "2026-08-27T04:33:12.728Z" }, ] [[package]] @@ -1034,7 +1034,7 @@ provides-extras = ["dev"] [[package]] name = "tinygrad" -version = "0.13.0" +version = "0.14.0" source = { editable = "tinygrad_repo" } [package.metadata] @@ -1048,6 +1048,7 @@ requires-dist = [ { name = "hypothesis", marker = "extra == 'testing-minimal'", specifier = ">=6.148.9" }, { name = "influxdb3-python", marker = "extra == 'testing'" }, { name = "librosa", marker = "extra == 'testing'" }, + { name = "mako", marker = "extra == 'autogen'" }, { name = "markdown-callouts", marker = "extra == 'docs'" }, { name = "markdown-exec", extras = ["ansi"], marker = "extra == 'docs'" }, { name = "mkdocs", marker = "extra == 'docs'" }, @@ -1062,7 +1063,7 @@ requires-dist = [ { name = "numpy", marker = "extra == 'testing-minimal'" }, { name = "onnx", marker = "extra == 'testing'", specifier = "==1.19.0" }, { name = "onnx2torch", marker = "extra == 'testing'" }, - { name = "onnxruntime", marker = "extra == 'testing'" }, + { name = "onnxruntime", marker = "extra == 'testing'", specifier = "==1.24.1" }, { name = "openai", marker = "extra == 'testing-unit'" }, { name = "opencv-python", marker = "extra == 'testing'" }, { name = "pandas", marker = "extra == 'testing'" }, @@ -1073,6 +1074,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'testing-minimal'" }, { name = "pytest-split", marker = "extra == 'testing-minimal'" }, { name = "pytest-xdist", marker = "extra == 'testing-minimal'" }, + { name = "pyyaml", marker = "extra == 'autogen'" }, { name = "ruff", marker = "extra == 'linting'", specifier = "==0.14.10" }, { name = "safetensors", marker = "extra == 'testing-unit'" }, { name = "sentencepiece", marker = "extra == 'testing'" }, @@ -1088,7 +1090,7 @@ requires-dist = [ { name = "typing-extensions", marker = "extra == 'linting'" }, { name = "z3-solver", marker = "extra == 'testing-minimal'", specifier = "<4.15.4" }, ] -provides-extras = ["linting", "testing-minimal", "testing-unit", "testing", "docs", "mesa"] +provides-extras = ["linting", "testing-minimal", "testing-unit", "testing", "docs", "mesa", "autogen"] [[package]] name = "tqdm" From e3def37695339d5d2f5e76e815ebb7570bcfdbd5 Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Thu, 3 Sep 2026 09:45:51 -0700 Subject: [PATCH 006/270] revert 38742 (#38760) --- openpilot/common/params_keys.h | 1 - openpilot/selfdrive/modeld/helpers.py | 6 ------ openpilot/selfdrive/modeld/modeld.py | 24 +++--------------------- 3 files changed, 3 insertions(+), 28 deletions(-) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 07a2283ece..ba6eae3dd5 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -135,6 +135,5 @@ inline static std::unordered_map keys = { {"UptimeOnroad", {PERSISTENT, FLOAT, "0.0"}}, {"ChestnutActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, {"ChestnutLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, - {"ChestnutModelError", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, {"Version", {PERSISTENT, STRING}}, }; diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 23eb6cbd21..d081050055 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -11,8 +11,6 @@ from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH MODELS_DIR = Path(__file__).resolve().parent / 'models' TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json' -CHESTNUT_POWERED_VOLTAGE = 5000 -CHESTNUT_PCIE_READY = 0x78 def get_tg_input_devices(process_name: str, chestnut: bool): @@ -60,7 +58,3 @@ def chestnut_present() -> bool: def chestnut_compiled() -> bool: return Path(get_manifest_path(modeld_pkl_path(chestnut=True))).is_file() - - -def chestnut_ready(state) -> bool: - return state.supplyVoltage >= CHESTNUT_POWERED_VOLTAGE and not state.supplyFault and state.pcieLtssm == CHESTNUT_PCIE_READY diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 040c6b980f..175c729782 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -33,7 +33,7 @@ from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_drivi from openpilot.common.file_chunker import open_file_chunked from openpilot.common.hardware.usb import CHESTNUT_USB_IDS from openpilot.selfdrive.modeld.constants import ModelConstants, Plan -from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, chestnut_ready, modeld_pkl_path, load_oob +from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') @@ -237,25 +237,12 @@ class ModelState: def main(demo=False): cloudlog.warning("modeld init") - chestnut_available = chestnut_present() and chestnut_compiled() - CHESTNUT = False - if chestnut_available: - poller = messaging.Poller() - sock = messaging.sub_sock("chestnutState", poller=poller, conflate=True) - deadline = time.monotonic() + 4. / SERVICE_LIST['deviceState'].frequency - while not CHESTNUT and (remaining := deadline - time.monotonic()) > 0.: - if not poller.poll(round(remaining * 1000)): - break - msg = messaging.recv_one_or_none(sock) - CHESTNUT = msg is not None and msg.valid and chestnut_ready(msg.chestnutState) + CHESTNUT = chestnut_present() and chestnut_compiled() if CHESTNUT: os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' params = Params() params.put_bool("ChestnutLoading", CHESTNUT) - if chestnut_available and not CHESTNUT: - params.put_bool("ChestnutActive", False) - else: - params.remove("ChestnutActive") + params.remove("ChestnutActive") config_realtime_process(7, 54) @@ -299,11 +286,7 @@ def main(demo=False): loader.start() loader.join(BIG_MODEL_TIMEOUT) model = big_model - if model is None: - params.put_bool("ChestnutModelError", True) params.put_bool("ChestnutActive", model is not None) - if model is not None: - params.remove("ChestnutModelError") small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or CHESTNUT else None if model is None: @@ -434,7 +417,6 @@ def main(demo=False): raise # fallback to small model cloudlog.exception("big model failed, fall back to small") - params.put_bool("ChestnutModelError", True) params.put_bool("ChestnutActive", False) model = small_model if chestnut_state is not None: From 675ff569818fdd3f0cb76cdf6c0ea1664869504b Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Fri, 4 Sep 2026 15:20:43 -0700 Subject: [PATCH 007/270] tools: add op docs command (#38770) --- .github/workflows/docs.yaml | 8 ++------ docs/README.md | 4 ++-- tools/op.sh | 7 +++++++ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index f4a8e7cb89..c7229190e7 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -26,16 +26,12 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: commaai/timeout@v1 - - uses: actions/checkout@v7 - with: - submodules: true + - run: ./tools/op.sh setup # Build - name: Build docs - run: | - git lfs pull - python docs/serve.py --build + run: ./tools/op.sh docs --build # Push to docs.comma.ai - uses: actions/checkout@v7 diff --git a/docs/README.md b/docs/README.md index d6a0126b39..dc6eee6905 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,10 +5,10 @@ The site is updated on pushes to master by this [workflow](../.github/workflows/ **1. Build the site** ``` bash -python docs/serve.py --build +op docs --build ``` **2. Run the site locally** (rebuilds on change) ``` bash -python docs/serve.py +op docs ``` diff --git a/tools/op.sh b/tools/op.sh index 3d7d17a76b..43c88a2a86 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -349,6 +349,11 @@ function op_clip() { op_run_command openpilot/tools/clip/run.py "$@" } +function op_docs() { + op_before_cmd + op_run_command python docs/serve.py "$@" +} + function op_check_agnos_update() { if [[ ! -f "/AGNOS" ]]; then return 0 @@ -447,6 +452,7 @@ function op_default() { echo -e " ${BOLD}replay${NC} Run Replay" echo -e " ${BOLD}cabana${NC} Run Cabana" echo -e " ${BOLD}clip${NC} Run clip (linux only)" + echo -e " ${BOLD}docs${NC} Build or serve the openpilot documentation" echo -e " ${BOLD}adb${NC} Run adb shell" echo -e " ${BOLD}ssh${NC} comma prime SSH helper" echo "" @@ -502,6 +508,7 @@ function _op() { test ) shift 1; op_test "$@" ;; replay ) shift 1; op_replay "$@" ;; clip ) shift 1; op_clip "$@" ;; + docs ) shift 1; op_docs "$@" ;; sim ) shift 1; op_sim "$@" ;; switch ) shift 1; op_switch "$@" ;; start ) shift 1; op_start "$@" ;; From 33790c855c7eac1c825a6b9f71cf9f58aa3462eb Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:30:45 -0700 Subject: [PATCH 008/270] op: support all the linuxes! (#38773) --- tools/op.sh | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/tools/op.sh b/tools/op.sh index 43c88a2a86..d4a0263973 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -123,23 +123,7 @@ function op_check_git() { function op_check_os() { echo "Checking for compatible os version..." if [[ "$OSTYPE" == "linux-gnu"* ]]; then - - if [ -f "/etc/os-release" ]; then - source /etc/os-release - case "$VERSION_CODENAME" in - "jammy" | "kinetic" | "noble" | "focal") - echo -e " ↳ [${GREEN}✔${NC}] Ubuntu $VERSION_CODENAME detected." - ;; - * ) - echo -e " ↳ [${RED}✗${NC}] Incompatible Ubuntu version $VERSION_CODENAME detected!" - return 1 - ;; - esac - else - echo -e " ↳ [${RED}✗${NC}] No /etc/os-release on your system. Make sure you're running on Ubuntu, or similar!" - return 1 - fi - + echo -e " ↳ [${GREEN}✔${NC}] Linux detected." elif [[ "$OSTYPE" == "darwin"* ]]; then echo -e " ↳ [${GREEN}✔${NC}] macOS detected." else @@ -222,6 +206,7 @@ EOF echo "Pulling git lfs files..." st="$(date +%s)" + git lfs install --local if ! retry 3 git lfs pull; then echo -e " ↳ [${RED}✗${NC}] Pulling git lfs files failed!" return 1 From 69ad376fefa74ba0335e607f2174309b9285ccc6 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:09:45 -0700 Subject: [PATCH 009/270] Revert "op: support all the linuxes!" (#38774) Revert "op: support all the linuxes! (#38773)" This reverts commit 33790c855c7eac1c825a6b9f71cf9f58aa3462eb. --- tools/op.sh | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tools/op.sh b/tools/op.sh index d4a0263973..43c88a2a86 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -123,7 +123,23 @@ function op_check_git() { function op_check_os() { echo "Checking for compatible os version..." if [[ "$OSTYPE" == "linux-gnu"* ]]; then - echo -e " ↳ [${GREEN}✔${NC}] Linux detected." + + if [ -f "/etc/os-release" ]; then + source /etc/os-release + case "$VERSION_CODENAME" in + "jammy" | "kinetic" | "noble" | "focal") + echo -e " ↳ [${GREEN}✔${NC}] Ubuntu $VERSION_CODENAME detected." + ;; + * ) + echo -e " ↳ [${RED}✗${NC}] Incompatible Ubuntu version $VERSION_CODENAME detected!" + return 1 + ;; + esac + else + echo -e " ↳ [${RED}✗${NC}] No /etc/os-release on your system. Make sure you're running on Ubuntu, or similar!" + return 1 + fi + elif [[ "$OSTYPE" == "darwin"* ]]; then echo -e " ↳ [${GREEN}✔${NC}] macOS detected." else @@ -206,7 +222,6 @@ EOF echo "Pulling git lfs files..." st="$(date +%s)" - git lfs install --local if ! retry 3 git lfs pull; then echo -e " ↳ [${RED}✗${NC}] Pulling git lfs files failed!" return 1 From c8603fb3ce3ec73411ee3abd8666f58a86ee3e30 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:09:40 -0700 Subject: [PATCH 010/270] op: point git-lfs config at the vendored binary (#38775) git-lfs is vendored in the venv, but `git lfs install` writes filters and hooks that invoke a bare `git-lfs`, which is only on PATH inside the venv. With filter.lfs.required set, any checkout from a plain shell fails. git runs filters and hooks from the worktree root, so a relative path into the venv resolves from anywhere. --- tools/op.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/op.sh b/tools/op.sh index 43c88a2a86..a77c386e49 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -222,6 +222,12 @@ EOF echo "Pulling git lfs files..." st="$(date +%s)" + git config --local filter.lfs.clean ".venv/bin/git-lfs clean -- %f" + git config --local filter.lfs.smudge ".venv/bin/git-lfs smudge -- %f" + git config --local filter.lfs.process ".venv/bin/git-lfs filter-process" + git config --local filter.lfs.required true + printf '#!/bin/sh\nexec .venv/bin/git-lfs pre-push "$@"\n' > "$(git rev-parse --git-path hooks)/pre-push" + chmod +x "$(git rev-parse --git-path hooks)/pre-push" if ! retry 3 git lfs pull; then echo -e " ↳ [${RED}✗${NC}] Pulling git lfs files failed!" return 1 From 0ec3a082c7ca3302c171b03ff5cd43be61309f13 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:10:04 -0700 Subject: [PATCH 011/270] op: support all the linuxes! (#38776) --- tools/op.sh | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/tools/op.sh b/tools/op.sh index a77c386e49..f17714e620 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -123,23 +123,7 @@ function op_check_git() { function op_check_os() { echo "Checking for compatible os version..." if [[ "$OSTYPE" == "linux-gnu"* ]]; then - - if [ -f "/etc/os-release" ]; then - source /etc/os-release - case "$VERSION_CODENAME" in - "jammy" | "kinetic" | "noble" | "focal") - echo -e " ↳ [${GREEN}✔${NC}] Ubuntu $VERSION_CODENAME detected." - ;; - * ) - echo -e " ↳ [${RED}✗${NC}] Incompatible Ubuntu version $VERSION_CODENAME detected!" - return 1 - ;; - esac - else - echo -e " ↳ [${RED}✗${NC}] No /etc/os-release on your system. Make sure you're running on Ubuntu, or similar!" - return 1 - fi - + echo -e " ↳ [${GREEN}✔${NC}] Linux detected." elif [[ "$OSTYPE" == "darwin"* ]]; then echo -e " ↳ [${GREEN}✔${NC}] macOS detected." else From f9dacd0d6bb60bb1372d913bf929d7db8d036658 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:45:12 -0700 Subject: [PATCH 012/270] cabana: build binary directly, drop wrapper script (#38781) --- openpilot/tools/cabana/.gitignore | 2 +- openpilot/tools/cabana/SConscript | 2 +- openpilot/tools/cabana/cabana | 10 ---------- openpilot/tools/cabana/tests/test_cabana_ui.py | 2 +- 4 files changed, 3 insertions(+), 13 deletions(-) delete mode 100755 openpilot/tools/cabana/cabana diff --git a/openpilot/tools/cabana/.gitignore b/openpilot/tools/cabana/.gitignore index 788b286372..c59f393869 100644 --- a/openpilot/tools/cabana/.gitignore +++ b/openpilot/tools/cabana/.gitignore @@ -1,5 +1,5 @@ bootstrap_icons.cc -_cabana_ui +cabana dbc/car_fingerprint_to_dbc.json tests/test_cabana diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index 81fd8877c4..0249a019bb 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -51,7 +51,7 @@ if arch == "Darwin": ui_env['FRAMEWORKS'] = ['OpenGL', 'Cocoa', 'IOKit', 'CoreFoundation', 'CoreVideo', 'CoreMedia', 'Security', 'VideoToolbox'] else: ui_libs += ['GL', 'dl'] -cabana_ui = ui_env.Program('_cabana_ui', ui_objs, LIBS=ui_libs) +cabana_ui = ui_env.Program('cabana', ui_objs, LIBS=ui_libs) if GetOption('extras'): cabana_core_test_env = env.Clone() diff --git a/openpilot/tools/cabana/cabana b/openpilot/tools/cabana/cabana deleted file mode 100755 index bdec614366..0000000000 --- a/openpilot/tools/cabana/cabana +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -e - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" -ROOT="$(cd "$DIR/../../../" && pwd)" - -cd "$ROOT" -scons -u "openpilot/tools/cabana/_cabana_ui" openpilot/cereal/messaging/bridge - -exec "$DIR/_cabana_ui" "$@" diff --git a/openpilot/tools/cabana/tests/test_cabana_ui.py b/openpilot/tools/cabana/tests/test_cabana_ui.py index 2cb0475041..aecd7e6ed7 100644 --- a/openpilot/tools/cabana/tests/test_cabana_ui.py +++ b/openpilot/tools/cabana/tests/test_cabana_ui.py @@ -8,6 +8,6 @@ CABANA_DIR = Path(__file__).parent.parent class TestCabanaUi(OpenpilotTestCase): def test_help(self): - result = subprocess.run(["./_cabana_ui", "-h"], cwd=CABANA_DIR, capture_output=True, text=True) + result = subprocess.run(["./cabana", "-h"], cwd=CABANA_DIR, capture_output=True, text=True) assert result.returncode == 0, result.stderr assert "Usage:" in result.stderr From a989bc0b50db714c9fa4f4bdbc789662c40291b6 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:13:15 -0700 Subject: [PATCH 013/270] cabana: misc UI tweaks (#38782) --- openpilot/tools/cabana/tests/test_cabana.cc | 6 ++-- openpilot/tools/cabana/ui/chart/chart.cc | 2 +- openpilot/tools/cabana/ui/icons.h | 32 +++++++++---------- openpilot/tools/cabana/ui/mainwin.cc | 3 +- openpilot/tools/cabana/ui/style.cc | 12 +++---- .../tools/cabana/ui/widgets/historylog.cc | 4 +-- .../tools/cabana/ui/widgets/videowidget.cc | 13 ++++---- .../tools/cabana/ui/widgets/videowidget.h | 2 +- openpilot/tools/cabana/utils/strings.cc | 6 ++-- 9 files changed, 40 insertions(+), 40 deletions(-) diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 09741cd578..3523a47e08 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -195,10 +195,10 @@ void test_format_seconds() { REQUIRE(utils::formatSeconds(0) == "00:00"); REQUIRE(utils::formatSeconds(59.4) == "00:59"); REQUIRE(utils::formatSeconds(-1) == "00:00"); - REQUIRE(utils::formatSeconds(61.234, true) == "01:01.234"); + REQUIRE(utils::formatSeconds(61.234, true) == "01:01.23"); REQUIRE(utils::formatSeconds(3599.9) == "59:59"); REQUIRE(utils::formatSeconds(3601) == "01:00:01"); - REQUIRE(utils::formatSeconds(3601.5, true) == "01:00:01.500"); + REQUIRE(utils::formatSeconds(3601.5, true) == "01:00:01.50"); const char *tz = getenv("TZ"); const bool had_tz = tz != nullptr; @@ -206,7 +206,7 @@ void test_format_seconds() { setenv("TZ", "UTC", 1); tzset(); REQUIRE(utils::formatSeconds(0, false, true) == "1970-01-01 00:00:00"); - REQUIRE(utils::formatSeconds(1700000000.123, true, true) == "2023-11-14 22:13:20.123"); + REQUIRE(utils::formatSeconds(1700000000.123, true, true) == "2023-11-14 22:13:20.12"); if (had_tz) { setenv("TZ", saved_tz.c_str(), 1); } else { diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 4dedc9074c..5d1389822e 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -488,7 +488,7 @@ void ChartView::showTip(double sec) { x = tooltip_x_; } ImVec2 pt(x, layout_.plot_area.Min.y); - text_list.insert(text_list.begin(), TipLine{.name = formatNumber(secondsAtPoint({x, 0}), 3)}); + text_list.insert(text_list.begin(), TipLine{.name = formatNumber(secondsAtPoint({x, 0}), 2)}); tip_label_.showText(pt, text_list, visible_rect); } diff --git a/openpilot/tools/cabana/ui/icons.h b/openpilot/tools/cabana/ui/icons.h index 3a736fb3c9..5c97666da4 100644 --- a/openpilot/tools/cabana/ui/icons.h +++ b/openpilot/tools/cabana/ui/icons.h @@ -4,37 +4,37 @@ namespace icon { constexpr const char ARROW_CLOCKWISE[] = "\xef\x84\x96"; constexpr const char ARROW_COUNTERCLOCKWISE[] = "\xef\x84\x97"; -constexpr const char ARROW_DOWN_LEFT_SQUARE[] = "\xef\x84\x9d"; -constexpr const char ARROW_UP_RIGHT_SQUARE[] = "\xef\x85\x83"; +constexpr const char ARROW_DOWN_LEFT_SQUARE[] = "\xef\x84\x9c"; +constexpr const char ARROW_UP_RIGHT_SQUARE[] = "\xef\x85\x82"; constexpr const char CHEVRON_LEFT[] = "\xef\x8a\x84"; constexpr const char CHEVRON_RIGHT[] = "\xef\x8a\x85"; constexpr const char DASH[] = "\xef\x8b\xaa"; -constexpr const char DASH_SQUARE[] = "\xef\x8b\xa9"; -constexpr const char EXCLAMATION_TRIANGLE[] = "\xef\x8c\xbb"; -constexpr const char FAST_FORWARD[] = "\xef\x9f\xb4"; +constexpr const char DASH_SQUARE[] = "\xef\x8b\xa8"; +constexpr const char EXCLAMATION_TRIANGLE[] = "\xef\x8c\xba"; +constexpr const char FAST_FORWARD[] = "\xef\x9f\xb3"; constexpr const char FILETYPE_CSV[] = "\xef\x9d\x83"; -constexpr const char FOLDER[] = "\xef\x8f\x99"; -constexpr const char FILE_EARMARK[] = "\xef\x8e\x92"; -constexpr const char FILE_EARMARK_RULED[] = "\xef\x8e\x85"; -constexpr const char PLUS_SQUARE[] = "\xef\x93\xbd"; +constexpr const char FOLDER[] = "\xef\x8f\x91"; +constexpr const char FILE_EARMARK[] = "\xef\x8d\xa9"; +constexpr const char FILE_EARMARK_RULED[] = "\xef\x8e\x84"; +constexpr const char PLUS_SQUARE[] = "\xef\x93\xbc"; constexpr const char GRAPH_UP[] = "\xef\x8f\xb2"; constexpr const char GRIP_HORIZONTAL[] = "\xef\x8f\xbd"; -constexpr const char INFO_CIRCLE[] = "\xef\x90\xb1"; +constexpr const char INFO_CIRCLE[] = "\xef\x90\xb0"; constexpr const char LIST[] = "\xef\x91\xb9"; -constexpr const char PAUSE[] = "\xef\x93\x84"; +constexpr const char PAUSE[] = "\xef\x93\x83"; constexpr const char PENCIL[] = "\xef\x93\x8b"; -constexpr const char PLAY[] = "\xef\x93\xb5"; +constexpr const char PLAY[] = "\xef\x93\xb4"; constexpr const char PLUS[] = "\xef\x93\xbe"; constexpr const char RAQUO[] = "\xc2\xbb"; // U+00BB, not a bootstrap icon: the toolbar extension button constexpr const char REPEAT[] = "\xef\xa0\x93"; constexpr const char REPEAT_1[] = "\xef\xa0\x92"; -constexpr const char REWIND[] = "\xef\xa0\x99"; -constexpr const char SKIP_END[] = "\xef\x95\x98"; -constexpr const char STOPWATCH[] = "\xef\x96\x97"; +constexpr const char REWIND[] = "\xef\xa0\x98"; +constexpr const char SKIP_END[] = "\xef\x95\x97"; +constexpr const char STOPWATCH[] = "\xef\x96\x96"; constexpr const char THREE_DOTS[] = "\xef\x97\x94"; constexpr const char WINDOW_STACK[] = "\xef\x9b\x92"; constexpr const char X[] = "\xef\x98\xaa"; constexpr const char X_LG[] = "\xef\x99\x99"; -constexpr const char X_SQUARE[] = "\xef\x98\xa9"; +constexpr const char X_SQUARE[] = "\xef\x98\xa8"; constexpr const char ZOOM_OUT[] = "\xef\x98\xad"; } // namespace icon diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 4a0aef7c79..80a6157d54 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -835,7 +835,8 @@ void MainWindow::drawVideoPanel() { const bool live = can->liveStreaming(); // the bordered child pads its content, so the heights the widget asks for grow by the padding const float video_padding = ImGui::GetStyle().WindowPadding.y * 2.0f; - const float default_h = video_widget_->defaultHeight(avail.x) + video_padding; + // the camera is as wide as the child's content region, not the panel + const float default_h = video_widget_->defaultHeight(avail.x - ImGui::GetStyle().WindowPadding.x * 2.0f) + video_padding; const float video_hint = video_splitter_ratio_ >= 0.0f ? avail.y * video_splitter_ratio_ : default_h; float video_h = charts_floating_ ? avail.y : std::clamp(video_hint, 0.0f, avail.y - 1.0f); if (live) video_h = default_h; // display video at minimum size. diff --git a/openpilot/tools/cabana/ui/style.cc b/openpilot/tools/cabana/ui/style.cc index 8cbedad837..a006dbc66d 100644 --- a/openpilot/tools/cabana/ui/style.cc +++ b/openpilot/tools/cabana/ui/style.cc @@ -100,9 +100,9 @@ void applyTheme(int theme) { colors[ImGuiCol_FrameBg] = c(DarkTheme::base); colors[ImGuiCol_FrameBgHovered] = colorRgb(0x1f, 0x1f, 0x1f); colors[ImGuiCol_FrameBgActive] = colorRgb(0x24, 0x24, 0x24); - colors[ImGuiCol_Button] = colorRgb(0x3a, 0x3a, 0x3a); - colors[ImGuiCol_ButtonHovered] = colorRgb(0x42, 0x42, 0x42); - colors[ImGuiCol_ButtonActive] = colorRgb(0x30, 0x30, 0x30); + colors[ImGuiCol_Button] = colorRgb(0x5e, 0x5e, 0x5e); + colors[ImGuiCol_ButtonHovered] = colorRgb(0x6a, 0x6a, 0x6a); + colors[ImGuiCol_ButtonActive] = colorRgb(0x52, 0x52, 0x52); colors[ImGuiCol_Header] = highlight; colors[ImGuiCol_HeaderHovered] = c(DarkTheme::highlight, 0.8f); colors[ImGuiCol_HeaderActive] = highlight; @@ -157,9 +157,9 @@ void applyTheme(int theme) { colors[ImGuiCol_FrameBg] = base; colors[ImGuiCol_FrameBgHovered] = colorRgb(0xf7, 0xf7, 0xf7); colors[ImGuiCol_FrameBgActive] = colorRgb(0xef, 0xef, 0xef); - colors[ImGuiCol_Button] = colorRgb(0xf5, 0xf5, 0xf5); - colors[ImGuiCol_ButtonHovered] = colorRgb(0xfa, 0xfa, 0xfa); - colors[ImGuiCol_ButtonActive] = colorRgb(0xd9, 0xd9, 0xd9); + colors[ImGuiCol_Button] = colorRgb(0xe4, 0xe4, 0xe4); + colors[ImGuiCol_ButtonHovered] = colorRgb(0xec, 0xec, 0xec); + colors[ImGuiCol_ButtonActive] = colorRgb(0xd0, 0xd0, 0xd0); colors[ImGuiCol_Header] = highlight; colors[ImGuiCol_HeaderHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.8f); colors[ImGuiCol_HeaderActive] = highlight; diff --git a/openpilot/tools/cabana/ui/widgets/historylog.cc b/openpilot/tools/cabana/ui/widgets/historylog.cc index 0a3de51d70..ec2ef844ff 100644 --- a/openpilot/tools/cabana/ui/widgets/historylog.cc +++ b/openpilot/tools/cabana/ui/widgets/historylog.cc @@ -22,7 +22,7 @@ constexpr float COMPARE_WIDTH = 50.0f; std::string formatTime(uint64_t mono_time) { char buf[32] = {}; - snprintf(buf, sizeof(buf), "%.3f", can->toSeconds(mono_time)); + snprintf(buf, sizeof(buf), "%.2f", can->toSeconds(mono_time)); return buf; } @@ -184,7 +184,7 @@ std::string LogsWidget::headerText(int column) const { } ImVec2 LogsWidget::headerSize(int column, float viewport_width) const { - const ImVec2 time_text_size = ImGui::CalcTextSize("000000.000"); + const ImVec2 time_text_size = ImGui::CalcTextSize("000000.00"); const ImVec2 time_col_size(time_text_size.x + 10, time_text_size.y + 6); if (column == 0) return time_col_size; const int default_size = std::max(100, (int)((viewport_width - time_col_size.x) / (columnCount() - 1))); diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index fa6b9c7e6c..ed5683f74b 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -534,20 +534,19 @@ void StreamCameraView::draw(const ImVec2 &size, double thumbnail_time) { } } -const RgbImage *StreamCameraView::thumbnailAt(double sec, uint64_t *mono_time) { +const RgbImage *StreamCameraView::thumbnailAt(double sec) { auto it = big_thumbnails_.lower_bound(can->toMonoTime(sec)); if (it == big_thumbnails_.end()) return nullptr; if (big_thumbnail_texture_.id == 0 || big_thumbnail_texture_.key != it->first) { big_thumbnail_texture_.upload(it->second); big_thumbnail_texture_.key = it->first; } - if (mono_time) *mono_time = it->first; return &it->second; } void StreamCameraView::drawScrubThumbnail(ImDrawList *p, double sec) { p->AddRectFilled(rect().Min, rect().Max, IM_COL32(0, 0, 0, 255)); - if (const RgbImage *image = thumbnailAt(sec, nullptr)) { + if (const RgbImage *image = thumbnailAt(sec)) { // scale to the widget size, keeping the aspect ratio const float scale = std::min(width() / image->width, height() / image->height); const ImVec2 scaled_size(std::floor(image->width * scale), std::floor(image->height * scale)); @@ -560,8 +559,7 @@ void StreamCameraView::drawScrubThumbnail(ImDrawList *p, double sec) { } void StreamCameraView::drawThumbnail(ImDrawList *p, double sec) { - uint64_t mono_time = 0; - if (const RgbImage *image = thumbnailAt(sec, &mono_time)) { + if (const RgbImage *image = thumbnailAt(sec)) { // AddImage scales the stored image to the thumbnail height, keeping the aspect ratio const int h = MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2; const int w = std::max(1, (int)std::lround((double)image->width * h / image->height)); @@ -574,7 +572,8 @@ void StreamCameraView::drawThumbnail(ImDrawList *p, double sec) { ImRect thumb_rect(ImVec2(rect().Min.x + x, rect().Min.y + y), ImVec2(rect().Min.x + x + w, rect().Min.y + y + h)); p->AddImage(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max); p->AddRect(thumb_rect.Min, thumb_rect.Max, paletteBrightText(), 0.0f, 0, 2.0f); - if (auto alert = getReplay()->findAlertAtTime(can->toSeconds(mono_time))) { + // look up the alert at the hovered time, the thumbnail frame itself can be seconds away + if (auto alert = getReplay()->findAlertAtTime(sec)) { drawAlert(p, thumb_rect, *alert, POINT_10_FONT_SIZE); } drawTime(p, thumb_rect, sec); @@ -583,7 +582,7 @@ void StreamCameraView::drawThumbnail(ImDrawList *p, double sec) { void StreamCameraView::drawTime(ImDrawList *p, const ImRect &rect, double seconds) { char text[32]; - snprintf(text, sizeof(text), "%.3f", seconds); + snprintf(text, sizeof(text), "%.2f", seconds); ImFont *font = ImGui::GetFont(); const ImVec2 text_size = font->CalcTextSizeA(POINT_10_FONT_SIZE, FLT_MAX, 0.0f, text); // centered horizontally, above the bottom margin diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h index 44c3cd5312..d873c3ed10 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.h +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -69,7 +69,7 @@ private: }; void collectThumbnails(); // moves the decoded thumbnails in once a parseQLog task is done // the first thumbnail at or after sec, uploaded to big_thumbnail_texture_; nullptr when there is none - const RgbImage *thumbnailAt(double sec, uint64_t *mono_time); + const RgbImage *thumbnailAt(double sec); void drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size); void drawThumbnail(ImDrawList *p, double sec); void drawScrubThumbnail(ImDrawList *p, double sec); diff --git a/openpilot/tools/cabana/utils/strings.cc b/openpilot/tools/cabana/utils/strings.cc index 5590b6078e..3a1191609e 100644 --- a/openpilot/tools/cabana/utils/strings.cc +++ b/openpilot/tools/cabana/utils/strings.cc @@ -21,7 +21,7 @@ std::string formatSeconds(double sec, bool include_milliseconds, bool absolute_t char buf[64] = {}; std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); if (!include_milliseconds) return buf; - snprintf(out, sizeof(out), "%s.%03d", buf, millis); + snprintf(out, sizeof(out), "%s.%02d", buf, millis / 10); return out; } @@ -33,11 +33,11 @@ std::string formatSeconds(double sec, bool include_milliseconds, bool absolute_t const int seconds = (total_ms / 1000) % 60; const int millis = total_ms % 1000; if (show_hours && include_milliseconds) { - snprintf(out, sizeof(out), "%02d:%02d:%02d.%03d", hours, minutes, seconds, millis); + snprintf(out, sizeof(out), "%02d:%02d:%02d.%02d", hours, minutes, seconds, millis / 10); } else if (show_hours) { snprintf(out, sizeof(out), "%02d:%02d:%02d", hours, minutes, seconds); } else if (include_milliseconds) { - snprintf(out, sizeof(out), "%02d:%02d.%03d", minutes, seconds, millis); + snprintf(out, sizeof(out), "%02d:%02d.%02d", minutes, seconds, millis / 10); } else { snprintf(out, sizeof(out), "%02d:%02d", minutes, seconds); } From 444be0a649e47b15b2de2146a18949673c55689c Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:49:37 -0700 Subject: [PATCH 014/270] cabana: pace frames instead of relying on vsync (#38783) On Wayland a vsynced glfwSwapBuffers blocks until the compositor sends a frame callback, which it stops doing while the window is on another workspace or otherwise off-screen. The main thread never gets back to glfwPollEvents, so Hyprland reports cabana as not responding. Run with swap interval 0 and pace the loop to the monitor refresh rate. --- openpilot/tools/cabana/ui/app.cc | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/openpilot/tools/cabana/ui/app.cc b/openpilot/tools/cabana/ui/app.cc index aab2bfe742..ce4678f223 100644 --- a/openpilot/tools/cabana/ui/app.cc +++ b/openpilot/tools/cabana/ui/app.cc @@ -1,8 +1,10 @@ #include "tools/cabana/ui/app.h" #include +#include #include #include +#include #include #include "imgui.h" @@ -68,8 +70,23 @@ void glfwErrorCallback(int error, const char *description) { fprintf(stderr, "GLFW error %d: %s\n", error, description); } -// vsync paces the loop: glfwSwapBuffers blocks until the next refresh. Throttling on top of that beats -// against the refresh rate and makes the camera view stutter. +void paceFrame() { + using clock = std::chrono::steady_clock; + static clock::duration period = [] { + const GLFWvidmode *mode = glfwGetVideoMode(glfwGetPrimaryMonitor()); + int hz = (mode != nullptr && mode->refreshRate > 0) ? mode->refreshRate : 60; + return std::chrono::duration_cast(std::chrono::duration(1.0 / hz)); + }(); + static clock::time_point next = clock::now(); + next += period; + auto now = clock::now(); + if (next < now) { + next = now; // fell behind (slow frame or hidden window): don't try to catch up + return; + } + std::this_thread::sleep_until(next); +} + void renderFrame(GLFWwindow *window, MainWindow *win) { glfwPollEvents(); deliverPendingFocusLoss(); @@ -98,6 +115,7 @@ void renderFrame(GLFWwindow *window, MainWindow *win) { glfwMakeContextCurrent(backup_context); } glfwSwapBuffers(window); + paceFrame(); } class GlfwRuntime { @@ -120,7 +138,7 @@ public: throw std::runtime_error("glfwCreateWindow failed"); } glfwMakeContextCurrent(window_); - glfwSwapInterval(1); + glfwSwapInterval(0); } ~GlfwRuntime() { From a4f7c50d2a52a5865a40da2ebc5004c82929a0ef Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:54:17 -0700 Subject: [PATCH 015/270] cabana: fix sparkline edges and stroke rendering (#38784) cabana: preserve sparkline edges and improve stroke rendering --- openpilot/tools/cabana/ui/chart/sparkline.cc | 71 +++++++++----------- 1 file changed, 30 insertions(+), 41 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/sparkline.cc b/openpilot/tools/cabana/ui/chart/sparkline.cc index e5fe87182d..cf0482c197 100644 --- a/openpilot/tools/cabana/ui/chart/sparkline.cc +++ b/openpilot/tools/cabana/ui/chart/sparkline.cc @@ -61,7 +61,10 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi } const double xscale = (sz.x - 1) / (double)range; - const double yscale = (sz.y - 3) / (max_val - min_val); + // Leave room for the stroke's antialiasing fringe and the 3x3 endpoint marker + // at both extrema, including the half-pixel offset applied by ImGui's strokes. + const double ypadding = std::min(3.0, sz.y / 2.0); + const double yscale = (sz.y - 2 * ypadding) / (max_val - min_val); const double span = points_.back().x - points_.front().x; bool draw_individual_points = (span * xscale / points_.size()) > 8.0; @@ -70,7 +73,7 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi render_points_.clear(); if (draw_individual_points) { for (const auto &p : points_) { - render_points_.emplace_back(p.x * xscale, 1.0 + (max_val - p.y) * yscale); + render_points_.emplace_back(p.x * xscale, ypadding + (max_val - p.y) * yscale); } } else if (is_flat_line) { double y = sz.y / 2.0; @@ -78,7 +81,7 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi render_points_.emplace_back(points_.back().x * xscale, y); } else { double prev_y = points_.front().y; - render_points_.emplace_back(points_.front().x * xscale, 1.0 + (max_val - prev_y) * yscale); + render_points_.emplace_back(points_.front().x * xscale, ypadding + (max_val - prev_y) * yscale); bool in_flat = false; for (size_t i = 1; i < points_.size(); ++i) { @@ -87,13 +90,13 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi if (std::abs(y - prev_y) < 1e-6) { in_flat = true; } else { - if (in_flat) render_points_.emplace_back(points_[i - 1].x * xscale, 1.0 + (max_val - prev_y) * yscale); - render_points_.emplace_back(p.x * xscale, 1.0 + (max_val - y) * yscale); + if (in_flat) render_points_.emplace_back(points_[i - 1].x * xscale, ypadding + (max_val - prev_y) * yscale); + render_points_.emplace_back(p.x * xscale, ypadding + (max_val - y) * yscale); in_flat = false; } prev_y = y; } - if (in_flat) render_points_.emplace_back(points_.back().x * xscale, 1.0 + (max_val - prev_y) * yscale); + if (in_flat) render_points_.emplace_back(points_.back().x * xscale, ypadding + (max_val - prev_y) * yscale); } size = sz; @@ -135,43 +138,29 @@ void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos, ImU32 color) const { // a point is a 3x3 square auto draw_point = [&](const ImVec2 &p) { draw_list->AddRectFilled(ImVec2(p.x - 1.5f, p.y - 1.5f), ImVec2(p.x + 1.5f, p.y + 1.5f), color); }; - if (draw_individual_points_) { - for (const auto &p : render_points_) { - draw_list->PathLineTo(point_at(p)); - draw_point(point_at(p)); - } - draw_list->PathStroke(color, ImDrawFlags_None, 1.5f); - } else { - // one sample per pixel column: several strokes in a column overlap into a blur, and a dense - // high-contrast texture scrolling by is hard on the eyes - std::vector pts; - pts.reserve(render_points_.size()); - float col = -1e9f; - for (const auto &p : render_points_) { - ImVec2 sp = point_at(p); - float c = snap(sp.x); - if (c != col) { - pts.push_back(sp); - col = c; + // Keep ordinary curves joined so short segments don't leave antialiasing seams. + // Split at turns sharper than 120 degrees: a joined narrow spike folds back on + // itself and loses its tip. Both paths retain the actual peak and every sample. + const float thickness = 1.5f; + for (size_t i = 0; i < render_points_.size(); ++i) { + const auto &p = render_points_[i]; + draw_list->PathLineTo(point_at(p)); + if (i > 0 && i + 1 < render_points_.size()) { + const auto &prev = render_points_[i - 1]; + const auto &next = render_points_[i + 1]; + const double ax = p.x - prev.x, ay = p.y - prev.y; + const double bx = next.x - p.x, by = next.y - p.y; + const double dot = ax * bx + ay * by; + if (dot < 0 && 4 * dot * dot > (ax * ax + ay * ay) * (bx * bx + by * by)) { + draw_list->PathStroke(color, ImDrawFlags_None, thickness); + draw_list->PathLineTo(point_at(p)); } } - - // antialiasing smooths the gentle slopes but smears the near-vertical segments of a spiky signal - // over neighboring columns, so those are drawn aliased. runs of one kind are stroked together and - // share their end points with the next run - auto steep = [&](size_t i) { return std::abs(pts[i + 1].y - pts[i].y) > 2.0f * std::abs(pts[i + 1].x - pts[i].x) + px; }; - const ImDrawListFlags saved = draw_list->Flags; - size_t i = 0; - while (i + 1 < pts.size()) { - const bool is_steep = steep(i); - size_t j = i + 1; - while (j + 1 < pts.size() && steep(j) == is_steep) ++j; - draw_list->Flags = is_steep ? (saved & ~ImDrawListFlags_AntiAliasedLines) : saved; - for (size_t n = i; n <= j; ++n) draw_list->PathLineTo(pts[n]); - draw_list->PathStroke(color, ImDrawFlags_None, 1.0f); - i = j; - } - draw_list->Flags = saved; + } + draw_list->PathStroke(color, ImDrawFlags_None, thickness); + if (draw_individual_points_) { + for (const auto &p : render_points_) draw_point(point_at(p)); + } else { draw_point(point_at(render_points_.back())); } draw_list->PopClipRect(); From cb0da74b414363f4cdb15c2897f8bb1202bc7e79 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:34:00 -0700 Subject: [PATCH 016/270] cabana: center speed dropdown label, add divider after loop button (#38785) --- openpilot/tools/cabana/ui/util.cc | 17 +++++---- openpilot/tools/cabana/ui/util.h | 2 +- .../tools/cabana/ui/widgets/videowidget.cc | 36 ++++++++++--------- 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index bd1b2f8039..2e538e16f1 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -442,18 +442,21 @@ bool menuButton(const char *id, const std::string &text, const char *popup_id, b // on press; a press while it is open toggles it closed (imgui closes the popup at the end of the frame of // a click outside it, so only open when it is not already open) if (bold) pushBoldFont(); - ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : ImVec4(0, 0, 0, 0)); - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); - ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.0f, 0.5f)); - const bool clicked = ImGui::ButtonEx((text + "###" + id).c_str(), ImVec2(width, 0.0f), ImGuiButtonFlags_PressedOnClick); - ImGui::PopStyleVar(2); - ImGui::PopStyleColor(); const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; const float ascent = ImGui::GetFontBaked()->Ascent; + // the text and the arrow are centered as a group in the button + const float padding_x = std::max(style.FramePadding.x, (width - (text_width + MENU_ARROW_SPACING + MENU_ARROW_SIZE)) * 0.5f); + ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : ImVec4(0, 0, 0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(padding_x, style.FramePadding.y)); + ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.0f, 0.5f)); + const bool clicked = ImGui::ButtonEx((text + "###" + id).c_str(), ImVec2(width, 0.0f), ImGuiButtonFlags_PressedOnClick); + ImGui::PopStyleVar(3); + ImGui::PopStyleColor(); if (bold) popBoldFont(); // a 6 px arrow right after the text, sitting on the text baseline const ImVec2 min = ImGui::GetItemRectMin(); - const float x = min.x + style.FramePadding.x + text_width + MENU_ARROW_SPACING; + const float x = min.x + padding_x + text_width + MENU_ARROW_SPACING; const float baseline = min.y + style.FramePadding.y + ascent; ImGui::GetWindowDrawList()->AddTriangleFilled(ImVec2(x, baseline - MENU_ARROW_SIZE * 0.5f), ImVec2(x + MENU_ARROW_SIZE, baseline - MENU_ARROW_SIZE * 0.5f), diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index 5739cfa3bc..d180c073b7 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -182,7 +182,7 @@ float toolbarWidth(const std::vector &items, size_t spacer_index); void drawToolbar(const std::vector &items, size_t spacer_index); // an auto-raise button that opens `popup_id` below itself, with a dropdown arrow after the text. width 0: -// sized to the text, otherwise the arrow sits at the right edge +// sized to the text, otherwise the text and the arrow are centered in the button float menuButtonWidth(const std::string &text, bool bold = false); bool menuButton(const char *id, const std::string &text, const char *popup_id, bool bold = false, float width = 0.0f); diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index ed5683f74b..9a54351ab6 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -143,7 +143,7 @@ static float toolbarHeight() { return TOOLBAR_MARGIN_Y + ImGui::GetFrameHeight() void VideoWidget::drawPlaybackController() { beginToolbar(); ImGui::SetCursorPosY(ImGui::GetCursorPosY() + TOOLBAR_MARGIN_Y); - const float speed_width = menuButtonWidth("0.05x ", true); + const float speed_width = menuButtonWidth("0.05x", true); const char *play_icon = can->isPaused() ? icon::PLAY : icon::PAUSE; const char *play_tooltip = can->isPaused() ? "Play" : "Pause"; @@ -186,21 +186,25 @@ void VideoWidget::drawPlaybackController() { } // the expanding spacer: the items after it are right aligned as long as everything fits const size_t spacer_index = items.size(); - if (!can->liveStreaming()) { - items.push_back({toolbarButtonWidth(loop_icon), [&]() { if (toolButton("loop", loop_icon, "Loop playback")) loopPlaybackClicked(); }, - "Loop playback", [this]() { loopPlaybackClicked(); }}); - } - items.push_back({speed_width, [&]() { drawSpeedDropdown(speed_width); }}); - if (!can->liveStreaming()) { - ToolbarItem separator{TOOLBAR_SEPARATOR_EXTENT, []() { + auto separator = []() { + ToolbarItem item{TOOLBAR_SEPARATOR_EXTENT, []() { // a 1 px separator line centered in TOOLBAR_SEPARATOR_EXTENT, inset from the top and bottom const ImVec2 min = ImGui::GetCursorScreenPos(); ImGui::Dummy(ImVec2(TOOLBAR_SEPARATOR_EXTENT, ImGui::GetFrameHeight())); const float x = std::floor(min.x + TOOLBAR_SEPARATOR_EXTENT * 0.5f); ImGui::GetWindowDrawList()->AddLine(ImVec2(x, min.y + 4.0f), ImVec2(x, min.y + ImGui::GetFrameHeight() - 4.0f), ImGui::GetColorU32(ImGuiCol_Separator)); }}; - separator.in_menu = false; - items.push_back(std::move(separator)); + item.in_menu = false; + return item; + }; + if (!can->liveStreaming()) { + items.push_back({toolbarButtonWidth(loop_icon), [&]() { if (toolButton("loop", loop_icon, "Loop playback")) loopPlaybackClicked(); }, + "Loop playback", [this]() { loopPlaybackClicked(); }}); + items.push_back(separator()); + } + items.push_back({speed_width, [&]() { drawSpeedDropdown(speed_width); }}); + if (!can->liveStreaming()) { + items.push_back(separator()); items.push_back({toolbarButtonWidth(icon::INFO_CIRCLE), [&]() { if (toolButton("route_info", icon::INFO_CIRCLE, "View route details")) showRouteInfo(); }, "View route details", [this]() { showRouteInfo(); }}); @@ -221,16 +225,16 @@ void VideoWidget::toggleTimeDisplay() { settings.absolute_time = !settings.absolute_time; } -static std::string speedText(float speed, const char *suffix) { +static std::string speedText(float speed) { char buf[32]; - snprintf(buf, sizeof(buf), "%gx%s", speed, suffix); + snprintf(buf, sizeof(buf), "%gx", speed); return buf; } void VideoWidget::createSpeedDropdown() { speed_index_ = NORMAL_SPEED_INDEX; can->setSpeed(speeds[speed_index_]); - speed_text_ = speedText(speeds[speed_index_], " "); + speed_text_ = speedText(speeds[speed_index_]); } void VideoWidget::drawSpeedDropdown(float width) { @@ -247,14 +251,14 @@ void VideoWidget::drawSpeedMenuItems() { const float indent = ImGui::GetFontSize(); float label_width = 0; for (int i = 0; i < (int)std::size(speeds); ++i) { - label_width = std::max(label_width, ImGui::CalcTextSize(speedText(speeds[i], "").c_str()).x); + label_width = std::max(label_width, ImGui::CalcTextSize(speedText(speeds[i]).c_str()).x); } for (int i = 0; i < (int)std::size(speeds); ++i) { const float speed = speeds[i]; - if (radioMenuItem(speedText(speed, "").c_str(), speed_index_ == i, indent + label_width + indent)) { + if (radioMenuItem(speedText(speed).c_str(), speed_index_ == i, indent + label_width + indent)) { speed_index_ = i; can->setSpeed(speed); - speed_text_ = speedText(speed, " "); + speed_text_ = speedText(speed); } } } From 3bc6701f34553a709d3d5c50be3b1265c77bb67d Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:02:05 -0700 Subject: [PATCH 017/270] cabana: fill video pane and reset video height (#38786) --- openpilot/tools/cabana/core/settings.h | 1 + openpilot/tools/cabana/settings.cc | 1 + openpilot/tools/cabana/ui/icons.h | 2 ++ openpilot/tools/cabana/ui/mainwin.cc | 1 + .../tools/cabana/ui/widgets/cameraview.cc | 16 +++------- .../tools/cabana/ui/widgets/cameraview.h | 31 +++++++++++++++++++ .../tools/cabana/ui/widgets/videowidget.cc | 22 +++++++------ .../tools/cabana/ui/widgets/videowidget.h | 1 + 8 files changed, 54 insertions(+), 21 deletions(-) diff --git a/openpilot/tools/cabana/core/settings.h b/openpilot/tools/cabana/core/settings.h index 1b9e2bcfc4..6032ee5665 100644 --- a/openpilot/tools/cabana/core/settings.h +++ b/openpilot/tools/cabana/core/settings.h @@ -21,6 +21,7 @@ struct CabanaSettingsState { bool multiple_lines_hex = false; bool log_livestream = true; bool suppress_defined_signals = false; + bool crop_video = true; std::string log_path; std::string last_dir; std::string last_route_dir; diff --git a/openpilot/tools/cabana/settings.cc b/openpilot/tools/cabana/settings.cc index 17a6611539..7f8e6473c2 100644 --- a/openpilot/tools/cabana/settings.cc +++ b/openpilot/tools/cabana/settings.cc @@ -488,6 +488,7 @@ void settingsOp(Store &s, SettingOperation op) { op(s, "log_path", settings.log_path); op(s, "drag_direction", (int &)settings.drag_direction); op(s, "suppress_defined_signals", settings.suppress_defined_signals); + op(s, "crop_video", settings.crop_video); op(s, "recent_dbc_file", settings.recent_dbc_file); op(s, "active_msg_id", settings.active_msg_id); op(s, "selected_msg_ids", settings.selected_msg_ids); diff --git a/openpilot/tools/cabana/ui/icons.h b/openpilot/tools/cabana/ui/icons.h index 5c97666da4..7a61d02c09 100644 --- a/openpilot/tools/cabana/ui/icons.h +++ b/openpilot/tools/cabana/ui/icons.h @@ -8,6 +8,8 @@ constexpr const char ARROW_DOWN_LEFT_SQUARE[] = "\xef\x84\x9c"; constexpr const char ARROW_UP_RIGHT_SQUARE[] = "\xef\x85\x82"; constexpr const char CHEVRON_LEFT[] = "\xef\x8a\x84"; constexpr const char CHEVRON_RIGHT[] = "\xef\x8a\x85"; +constexpr const char ASPECT_RATIO[] = "\xef\x85\x90"; +constexpr const char ASPECT_RATIO_FILL[] = "\xef\x85\x8f"; constexpr const char DASH[] = "\xef\x8b\xaa"; constexpr const char DASH_SQUARE[] = "\xef\x8b\xa8"; constexpr const char EXCLAMATION_TRIANGLE[] = "\xef\x8c\xba"; diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 80a6157d54..a2969defcc 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -156,6 +156,7 @@ void MainWindow::drawMenuBar() { ImGui::Separator(); if (ImGui::MenuItem("Reset Window Layout")) { messages_visible_ = video_visible_ = true; + video_splitter_ratio_ = -1.0f; reset_layout_ = true; } ImGui::EndMenu(); diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.cc b/openpilot/tools/cabana/ui/widgets/cameraview.cc index 78ca15116b..6ffd099fe9 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.cc +++ b/openpilot/tools/cabana/ui/widgets/cameraview.cc @@ -9,6 +9,7 @@ #include "common/yuv.h" #include "tools/cabana/utils/util.h" +#include "tools/cabana/settings.h" namespace { constexpr GLenum GL_LINEAR_MIPMAP_LINEAR_ = 0x2703; @@ -107,21 +108,12 @@ void CameraWidget::paint() { frame_updated_ = false; } - // Scale for aspect ratio - float widget_ratio = (float)width() / height(); - 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)); - ImVec2 video_min(rect_.Min.x + (int)(width() - w) / 2, rect_.Min.y + (int)(height() - h) / 2); - ImVec2 video_max(video_min.x + w, video_min.y + h); - - ImVec2 uv0(0, 0), uv1(1, 1); + VideoPlacement placement = videoPlacement(rect_, frameAspectRatio(), settings.crop_video); if (active_stream_type_ == VISION_STREAM_CABIN) { // mirror cabin camera horizontally - uv0.x = 1; - uv1.x = 0; + std::swap(placement.uv0.x, placement.uv1.x); } - p->AddImage(frame_texture_.ref(), video_min, video_max, uv0, uv1); + p->AddImage(frame_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1); } void CameraWidget::vipcThread() { diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.h b/openpilot/tools/cabana/ui/widgets/cameraview.h index 381e13dc50..2812aea86e 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.h +++ b/openpilot/tools/cabana/ui/widgets/cameraview.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include #include @@ -16,6 +18,35 @@ #include "tools/cabana/core/observable.h" #include "msgq/visionipc/visionipc_client.h" +// Center-crop the source to fill the destination without stretching. +inline ImVec2 videoFillUv(const ImVec2 &size, float aspect_ratio) { + const float ratio = size.x / size.y / aspect_ratio; + return ImVec2(0.5f * (1.0f - std::min(ratio, 1.0f)), + 0.5f * (1.0f - std::min(1.0f / ratio, 1.0f))); +} + +struct VideoPlacement { + ImVec2 min; + ImVec2 max; + ImVec2 uv0; + ImVec2 uv1; +}; + +inline VideoPlacement videoPlacement(const ImRect &rect, float source_aspect_ratio, bool crop) { + VideoPlacement placement{rect.Min, rect.Max, ImVec2(0, 0), ImVec2(1, 1)}; + if (crop) { + placement.uv0 = videoFillUv(rect.GetSize(), source_aspect_ratio); + placement.uv1 = ImVec2(1.0f - placement.uv0.x, 1.0f - placement.uv0.y); + } else { + const float widget_aspect_ratio = rect.GetWidth() / rect.GetHeight(); + const int width = std::lround(rect.GetWidth() * std::min(source_aspect_ratio / widget_aspect_ratio, 1.0f)); + const int height = std::lround(rect.GetHeight() * std::min(widget_aspect_ratio / source_aspect_ratio, 1.0f)); + placement.min = ImVec2(rect.Min.x + (int)(rect.GetWidth() - width) / 2, rect.Min.y + (int)(rect.GetHeight() - height) / 2); + placement.max = ImVec2(placement.min.x + width, placement.min.y + height); + } + return placement; +} + // tightly packed RGBA pixels struct RgbImage { int width = 0; diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 9a54351ab6..7cabac840a 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -197,10 +197,14 @@ void VideoWidget::drawPlaybackController() { item.in_menu = false; return item; }; + const char *aspect_ratio_icon = settings.crop_video ? icon::ASPECT_RATIO_FILL : icon::ASPECT_RATIO; + items.push_back({toolbarButtonWidth(aspect_ratio_icon), [&]() { + if (toolButton("crop_video", aspect_ratio_icon, "Crop to fill")) cropVideoClicked(); + }, "Crop to fill", [this]() { cropVideoClicked(); }}); if (!can->liveStreaming()) { + items.push_back(separator()); items.push_back({toolbarButtonWidth(loop_icon), [&]() { if (toolButton("loop", loop_icon, "Loop playback")) loopPlaybackClicked(); }, "Loop playback", [this]() { loopPlaybackClicked(); }}); - items.push_back(separator()); } items.push_back({speed_width, [&]() { drawSpeedDropdown(speed_width); }}); if (!can->liveStreaming()) { @@ -313,6 +317,11 @@ void VideoWidget::loopPlaybackClicked() { getReplay()->setLoop(!getReplay()->loop()); } +void VideoWidget::cropVideoClicked() { + settings.crop_video = !settings.crop_video; + settings.changed(); +} + void VideoWidget::timeRangeChanged() { const auto time_range = can->timeRange(); if (can->liveStreaming()) { @@ -551,14 +560,9 @@ const RgbImage *StreamCameraView::thumbnailAt(double sec) { void StreamCameraView::drawScrubThumbnail(ImDrawList *p, double sec) { p->AddRectFilled(rect().Min, rect().Max, IM_COL32(0, 0, 0, 255)); if (const RgbImage *image = thumbnailAt(sec)) { - // scale to the widget size, keeping the aspect ratio - const float scale = std::min(width() / image->width, height() / image->height); - const ImVec2 scaled_size(std::floor(image->width * scale), std::floor(image->height * scale)); - const ImVec2 center = rect().GetCenter(); - const ImVec2 thumb_min(center.x - (int)(scaled_size.x / 2), center.y - (int)(scaled_size.y / 2)); - ImRect thumb_rect(thumb_min, ImVec2(thumb_min.x + scaled_size.x, thumb_min.y + scaled_size.y)); - p->AddImage(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max); - drawTime(p, thumb_rect, sec); + const VideoPlacement placement = videoPlacement(rect(), (float)image->width / image->height, settings.crop_video); + p->AddImage(big_thumbnail_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1); + drawTime(p, rect(), sec); } } diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h index d873c3ed10..90058850c3 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.h +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -105,6 +105,7 @@ private: void drawSpeedDropdown(float width); void drawSpeedMenuItems(); void loopPlaybackClicked(); + void cropVideoClicked(); void vipcAvailableStreamsUpdated(std::set streams); void showRouteInfo(); From c51e3e5a71d6ea25e59c0683d683c1092c7c3f69 Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Sat, 5 Sep 2026 19:03:12 -0700 Subject: [PATCH 018/270] =?UTF-8?q?cinque=20terre=20model=20=F0=9F=87=AE?= =?UTF-8?q?=F0=9F=87=B9=20=20(#38771)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a4c5f1d1-1f5d-4807-9593-54afa27099d5/12864 --- openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx index 7b51732275..af98beabcb 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx +++ b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1791d5940b2c048d0639813426dd2cf1d6f2a6727ed51e17c8bcea8bbe754123 +oid sha256:e8d821733be15ebe9e27498bc27ad8bbbd741980ece37d77f377294010b8ff28 size 765950064 From 9d1f0d417111c89a8917d45180668f9ac644bbad Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:46:02 -0700 Subject: [PATCH 019/270] cabana: restyle (#38789) * cabana: connect palette theme, rounded cards, floating page switch Move the jotpluggler port's connect palette into a shared theme module (ui/theme.h/.cc) and apply it to all of cabana: Palette struct for dark and light, applyTheme fills ImGui and ImPlot styles, readableCurveColor and sectionTitle for reuse by the analysis workspace. Drop the old DarkTheme constants and per-theme color branches in widgets. Docked panels are borderless with content in rounded bordered cards; custom-drawn rects use the style radii. The detail widget's bottom tab bar is replaced by a floating Messages/Logs pill. * cabana: scrollbar and menu contrast, page switch below the page The scrollbar grab uses the border color on the window-colored track. Selection colors are stronger so an open menu reads as blue in both themes and the hovered item is visible in light mode. The Messages/Logs pill sits in a strip below the page instead of covering its last rows. * cabana: even spacing around the page switch, steady top menu highlight * cabana: inner spacing before the clear button of a clearable input * cabana: center the page switch below the page, space the signal row buttons * cabana: logs table cells padded and bordered like the messages table * cabana: recess the signals card in the window color * cabana: one button vocabulary Three button shapes, all one frame height with the style padding and rounding: ImGui::Button for framed text, iconButton for a framed square icon, toolButton for a flat square icon (or flat text). Adjacent buttons are ItemInnerSpacing apart, groups ItemSpacing apart. Tool bars drop their private padding and 1 px spacing, the signal row, chart header, detail header, logs export and signal selector buttons all use the shared sizes, and the page switch is built from the same style values. * cabana: center the glyph ink in square icon buttons * cabana: framed tool buttons and plain icon glyphs toolButton is now iconButton with an optional text, so every button in the app is framed. Boxed glyphs (plus-square, x-square, dash-square, arrow-*-square, window-stack) are replaced by their plain variants (plus-lg, trash, arrows-collapse, box-arrow-*, window-plus), the light x/plus/dash by their -lg forms, the chart menu by three-dots-vertical and the toolbar overflow by chevron-double-right. * cabana: draw square button glyphs at 80% so full bleed icons keep a margin * cabana: signal values in the mono font * cabana: center square button icons on their atlas ink, snapped to framebuffer pixels * cabana: draw square button glyph quads directly, AddText truncates the position * cabana: right align the messages and signals tool bars to the table edge, full size collapse button * cabana: detail header and tab scroll buttons flush with the right edge * cabana: header and footer strips, gap above the panels, video splitter as a separator The menu bar and the status bar are strips in the surface color with a separator line towards the panels, and the dockspace sits one item spacing below the menu bar, matching the spacing above the status bar. The video/charts splitter draws the same 1 px separator line as the dock splitters, centered in a gap as tall as the side padding, and a double click snaps the video back to its natural height. * cabana: top menus flush with the menu bar, 2 px video splitter without double click * cabana: dropdowns continue the menu bar, splitter in the border color, row button gap Top level menus draw no top border and square top corners, and the menu bar leaves its separator out under the open dropdown. The video splitter uses the border color like the dock splitters. The signal row buttons keep one inner spacing before the scrollbar. * cabana: dropdowns share the bar's 1 px border line, messages toolbar spacing matches the video card * cabana: tool bar groups, framed dropdown buttons, chart content flush with the tool bar Tool bar items are ItemSpacing apart and the items of a group (a button pair, a separator and its neighbor) ItemInnerSpacing apart, instead of a private 1 px spacing. The dropdown buttons are framed like the other buttons. The video time is a plain mono readout that toggles on click. Chart cards have no horizontal margin so their header and plot line up with the tool bar above. * cabana: chart list spacing: gap above the scroll area, charts clear of the scrollbar, even legend gaps * cabana: loop and speed buttons grouped like the playback buttons * cabana: survive narrow panels and small windows Stress tested by dragging the column and video splitters to their limits and shrinking the window to 640x480. Fixes: the messages tool bar and the message detail header overflow into the >> menu instead of clipping (the view button and the name stay); the binary view keeps a minimum cell width and scrolls sideways; the Messages/Logs switch drops to icons when the strip is too narrow; the camera keeps its last frame across reconnects, so a video restored from a collapsed splitter while paused is no longer black. * cabana: range slider spans the charts >> menu, remove all and float as menu entries * cabana: the charts card keeps its tool bar row or collapses, like the video at the other end * cabana: uniform >> menus: dropdowns become submenus, the time readout an action entry * cabana: video/charts splitter gap matches the column splitters * cabana: drop unused lambda capture in chart type menu Claude-Session: https://claude.ai/code/session_01XTFaJpf235LeHBmyFRgeJ1 * cabana: the menu bar and the status bar stay in full screen * cabana: simplify theme helpers and toolbar actions * cabana: preserve panel width for overflow edit dialog * cabana: keep message editor usable in narrow panels * cabana: group heatmap modes in a dropdown * cabana: trim redundant restyle comments * cabana: fix review findings from the restyle - set the caret and other unset colors so the light theme has no white caret - tolerate float error in the toolbar fit check to stop spurious overflow - restore the full screen chrome hiding - clear stale camera frames when the server changes, keep them across a collapse - cache icon glyph ink bounds instead of scanning the atlas every frame - drop dead plot_bg and ImPlot globals, add a badge palette role - add toolbarMenu and use toolbarAction for the remove message item - derive the signal view button size seed from the font and style * cabana: remove remaining redundant comments --- openpilot/tools/cabana/ui/chart/chart.cc | 60 ++-- .../tools/cabana/ui/chart/chartswidget.cc | 80 +++-- .../tools/cabana/ui/chart/signalselector.cc | 6 +- openpilot/tools/cabana/ui/chart/tiplabel.cc | 9 +- openpilot/tools/cabana/ui/helpoverlay.cc | 7 +- openpilot/tools/cabana/ui/icons.h | 22 +- openpilot/tools/cabana/ui/mainwin.cc | 102 +++++-- openpilot/tools/cabana/ui/style.cc | 284 ------------------ openpilot/tools/cabana/ui/theme.cc | 174 +++++++++++ openpilot/tools/cabana/ui/theme.h | 49 +++ openpilot/tools/cabana/ui/util.cc | 182 +++++++++-- openpilot/tools/cabana/ui/util.h | 55 +--- .../tools/cabana/ui/widgets/binaryview.cc | 13 +- .../tools/cabana/ui/widgets/cameraview.cc | 14 +- .../tools/cabana/ui/widgets/detailwidget.cc | 158 +++++----- .../tools/cabana/ui/widgets/detailwidget.h | 2 +- .../tools/cabana/ui/widgets/historylog.cc | 19 +- .../tools/cabana/ui/widgets/messagebytes.cc | 2 +- .../tools/cabana/ui/widgets/messageswidget.cc | 40 +-- .../cabana/ui/widgets/scrollabletabbar.cc | 2 +- .../tools/cabana/ui/widgets/signalview.cc | 62 ++-- .../tools/cabana/ui/widgets/videowidget.cc | 70 ++--- .../tools/cabana/ui/widgets/videowidget.h | 1 - openpilot/tools/cabana/utils/util.h | 14 - 24 files changed, 736 insertions(+), 691 deletions(-) delete mode 100644 openpilot/tools/cabana/ui/style.cc create mode 100644 openpilot/tools/cabana/ui/theme.cc create mode 100644 openpilot/tools/cabana/ui/theme.h diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 5d1389822e..ea54fc819b 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -19,7 +19,8 @@ const int AXIS_X_TOP_MARGIN = 4; const int X_TICK_COUNT = 5; const double MIN_ZOOM_SECONDS = 0.01; // 10ms const double EPSILON = 1e-6; -constexpr ImVec4 LAYOUT_MARGINS{8, 6, 8, 6}; // left, top, right, bottom +constexpr ImVec4 LAYOUT_MARGINS{0, 6, 0, 6}; // left, top, right, bottom +constexpr int LEGEND_SPACING = 5; static inline bool xLessThan(const ImPlotPoint &p, double x) { return p.x < (x - EPSILON); } static inline bool isNull(const ImPlotPoint &p) { return p.x == 0 && p.y == 0; } @@ -70,10 +71,10 @@ void ChartView::drawMenuActions() { // the buttons and their menus are drawn every frame, at the rects updateLayout() placed them at void ChartView::createToolButtons() { ImGui::SetCursorScreenPos(layout_.close_btn_rect.Min); - bool close_clicked = toolButton("close_btn", icon::X, "Remove Chart"); + bool close_clicked = iconButton("close_btn", icon::X_LG, "Remove Chart"); ImGui::SetCursorScreenPos(layout_.manage_btn_rect.Min); - if (toolButton("manage_btn", icon::LIST, "")) ImGui::OpenPopup("manage_menu"); + if (iconButton("manage_btn", icon::THREE_DOTS_VERTICAL, "")) ImGui::OpenPopup("manage_menu"); if (ImGui::BeginPopup("manage_menu")) { drawMenuActions(); ImGui::EndPopup(); @@ -136,27 +137,25 @@ void ChartView::updateLayout() { const ImVec2 grip = ImGui::CalcTextSize(icon::GRIP_HORIZONTAL); const ImVec2 top_left = layout_.rect.Min + ImVec2(LAYOUT_MARGINS.x, LAYOUT_MARGINS.y); layout_.move_icon_rect = ImRect(top_left, top_left + grip); - const ImVec2 pad = ImGui::GetStyle().FramePadding * 2; - const ImVec2 close_size = ImGui::CalcTextSize(icon::X) + pad; - const ImVec2 manage_size = ImGui::CalcTextSize(icon::LIST) + pad; - const ImVec2 close_min(layout_.rect.Max.x - LAYOUT_MARGINS.z - close_size.x, top_left.y); - layout_.close_btn_rect = ImRect(close_min, close_min + close_size); - const ImVec2 manage_min(close_min.x - manage_size.x - ImGui::GetStyle().ItemSpacing.x, top_left.y); - layout_.manage_btn_rect = ImRect(manage_min, manage_min + manage_size); + const ImVec2 btn_size(iconButtonWidth(), iconButtonWidth()); + const ImVec2 close_min(layout_.rect.Max.x - LAYOUT_MARGINS.z - btn_size.x, top_left.y); + layout_.close_btn_rect = ImRect(close_min, close_min + btn_size); + const ImVec2 manage_min(close_min.x - btn_size.x - ImGui::GetStyle().ItemInnerSpacing.x, top_left.y); + layout_.manage_btn_rect = ImRect(manage_min, manage_min + btn_size); ImFont *bold = boldFont(); const float font_size = ImGui::GetFontSize(); const float fm_height = ImGui::GetTextLineHeight(); const int marker_size = markerSize(); const int row_height = std::max(marker_size, fm_height) + fm_height + 3; // + the signal value line - const int legend_left = layout_.move_icon_rect.Max.x + LAYOUT_MARGINS.x; + const int legend_left = layout_.move_icon_rect.Max.x + LEGEND_SPACING; const int legend_right = std::max(layout_.manage_btn_rect.Min.x - LAYOUT_MARGINS.z, legend_left + 10); // layout legend entries left-to-right, wrapping between the move icon and the buttons layout_.legend_rects.clear(); int x = legend_left, y = top_left.y; for (auto &s : sigs_) { - int w = marker_size + 5 + bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x + + int w = marker_size + LEGEND_SPACING + bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x + ImGui::CalcTextSize(msgLabel(s.msg_id).c_str()).x; w = std::min(w, legend_right - legend_left); // keep oversized entries clear of the header buttons if (x + w > legend_right && x > legend_left) { @@ -531,13 +530,13 @@ void ChartView::paint() { drawStaticLayer(); if (can_drop_) { - ImGui::GetWindowDrawList()->AddRect(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_Header), 0.0f, 0, 4.0f); + ImGui::GetWindowDrawList()->AddRect(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_Header), ImGui::GetStyle().ChildRounding, 0, 4.0f); } } void ChartView::drawStaticLayer() { ImDrawList *painter = ImGui::GetWindowDrawList(); - painter->AddRectFilled(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_ChildBg)); + painter->AddRectFilled(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_ChildBg), ImGui::GetStyle().ChildRounding); ImGui::SetCursorScreenPos(layout_.move_icon_rect.Min); ImGui::InvisibleButton("grip", layout_.move_icon_rect.GetSize()); if (ImGui::IsItemActivated()) charts_widget_->startChartDrag(this, ImGui::GetMousePos()); @@ -555,23 +554,11 @@ void ChartView::drawAxes() { ImPlot::PushStyleVar(ImPlotStyleVar_PlotPadding, ImVec2(LAYOUT_MARGINS.x, AXIS_X_TOP_MARGIN)); ImPlot::PushStyleColor(ImPlotCol_PlotBg, ImVec4(0, 0, 0, 0)); ImPlot::PushStyleColor(ImPlotCol_FrameBg, ImVec4(0, 0, 0, 0)); - // every tick is a 1 px line in the text color at alpha 50, the edge ticks close the box, no tick marks. - // that alpha washes out on the dark base, so the dark theme draws opaque guides in a mid gray instead. - const bool dark = isDarkTheme(); - ImVec4 grid_color; - if (dark) { - grid_color = colorRgb(DarkTheme::light.r, DarkTheme::light.g, DarkTheme::light.b); - } else { - grid_color = ImGui::GetStyleColorVec4(ImGuiCol_Text); - grid_color.w = 50.0f / 255.0f; - } - ImPlot::PushStyleColor(ImPlotCol_AxisGrid, grid_color); - ImPlot::PushStyleColor(ImPlotCol_PlotBorder, grid_color); + ImPlot::PushStyleColor(ImPlotCol_PlotBorder, palette().grid); ImPlot::PushStyleColor(ImPlotCol_AxisTick, ImVec4(0, 0, 0, 0)); ImPlot::PushStyleColor(ImPlotCol_AxisText, ImGui::GetStyleColorVec4(ImGuiCol_Text)); ImPlot::PushStyleVar(ImPlotStyleVar_MajorTickLen, ImVec2(0, 0)); - // MajorGridSize is the per-axis line thickness; thicker guides read better on the dark base - ImPlot::PushStyleVar(ImPlotStyleVar_MajorGridSize, dark ? ImVec2(2.0f, 2.0f) : ImVec2(1.0f, 1.0f)); + ImPlot::PushStyleVar(ImPlotStyleVar_MajorGridSize, ImVec2(1.0f, 1.0f)); const ImPlotFlags flags = ImPlotFlags_NoTitle | ImPlotFlags_NoLegend | ImPlotFlags_NoMenus | ImPlotFlags_NoMouseText | ImPlotFlags_NoBoxSelect | ImPlotFlags_NoInputs | ImPlotFlags_NoFrame; const ImPlotAxisFlags axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight | ImPlotAxisFlags_NoSideSwitch | ImPlotAxisFlags_Lock; @@ -599,7 +586,7 @@ void ChartView::drawAxes() { drawForeground(); ImPlot::EndPlot(); } - ImPlot::PopStyleColor(6); + ImPlot::PopStyleColor(5); ImPlot::PopStyleVar(3); } @@ -632,7 +619,7 @@ void ChartView::drawLegend() { drawColorMarker(painter, r.Min, toImU32(s.color)); } - float x = r.Min.x + marker_size + 5; + float x = r.Min.x + marker_size + LEGEND_SPACING; const float text_y = r.GetCenter().y - font_size / 2.0f; addTextEllipsis(painter, bold, title_color, ImVec2(x, text_y), r.Max.x, s.sig->name); float name_w = std::min(bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x, r.Max.x - x); @@ -641,7 +628,7 @@ void ChartView::drawLegend() { addTextEllipsis(painter, normal, msg_color, ImVec2(x, text_y), r.Max.x, msg); if (!s.visible) { // strike out const float y = r.GetCenter().y; - painter->AddLine(ImVec2(r.Min.x + marker_size + 5, y), ImVec2(std::min(x + ImGui::CalcTextSize(msg.c_str()).x, r.Max.x), y), title_color); + painter->AddLine(ImVec2(r.Min.x + marker_size + LEGEND_SPACING, y), ImVec2(std::min(x + ImGui::CalcTextSize(msg.c_str()).x, r.Max.x), y), title_color); } } } @@ -722,19 +709,19 @@ void ChartView::drawRubberBandTimeRange() { ImDrawList *painter = ImPlot::GetPlotDrawList(); // ImGuiCol_Header is translucent, so the 1px selection outline is drawn at full alpha const ImU32 highlight = withAlpha(ImGui::GetColorU32(ImGuiCol_Header), 255); - painter->AddRectFilled(rubber_rect_.Min, rubber_rect_.Max, withAlpha(highlight, 50)); - painter->AddRect(rubber_rect_.Min, rubber_rect_.Max, highlight); + painter->AddRectFilled(rubber_rect_.Min, rubber_rect_.Max, withAlpha(highlight, 50), ImGui::GetStyle().FrameRounding); + painter->AddRect(rubber_rect_.Min, rubber_rect_.Max, highlight, ImGui::GetStyle().FrameRounding); // time labels at the bottom corners (below the plot, so clip to the widget instead of the plot) const ImU32 white = IM_COL32_WHITE; - const ImU32 gray = IM_COL32(0xa0, 0xa0, 0xa4, 0xff); + const ImU32 badge = ImGui::GetColorU32(palette().badge); painter = ImGui::GetWindowDrawList(); painter->PushClipRect(layout_.rect.Min, layout_.rect.Max); for (const auto &pt : {rubber_rect_.GetBL(), rubber_rect_.GetBR()}) { std::string sec = formatNumber(secondsAtPoint(pt), 2); ImVec2 size = ImGui::CalcTextSize(sec.c_str()) + ImVec2(12, AXIS_X_TOP_MARGIN * 2); ImVec2 top_left = pt.x == rubber_rect_.Min.x ? ImVec2(pt.x - size.x, pt.y + 2) : ImVec2(pt.x, pt.y + 2); - painter->AddRectFilled(top_left, top_left + size, gray); + painter->AddRectFilled(top_left, top_left + size, badge, ImGui::GetStyle().FrameRounding); painter->AddText(top_left + ImVec2(6, AXIS_X_TOP_MARGIN), white, sec.c_str()); } painter->PopClipRect(); @@ -748,8 +735,7 @@ void ChartView::drawTimeline() { std::string time_str = formatNumber(cur_sec_, 2); ImVec2 time_str_size = ImGui::CalcTextSize(time_str.c_str()) + ImVec2(8, 2); ImVec2 time_str_pos(x - time_str_size.x / 2.0f, layout_.plot_area.Max.y + AXIS_X_TOP_MARGIN); - const bool dark = isDarkTheme(); - painter->AddRectFilled(time_str_pos, time_str_pos + time_str_size, dark ? IM_COL32(0x80, 0x80, 0x80, 0xff) : IM_COL32(0xa0, 0xa0, 0xa4, 0xff), 3.0f); + painter->AddRectFilled(time_str_pos, time_str_pos + time_str_size, ImGui::GetColorU32(palette().badge), ImGui::GetStyle().FrameRounding); painter->AddText(time_str_pos + ImVec2(4, 1), IM_COL32_WHITE, time_str.c_str()); } diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index 546705d395..d10300cbe8 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -17,7 +17,6 @@ const int MAX_COLUMN_COUNT = 4; const int CHART_SPACING = 4; const int START_DRAG_DISTANCE = 10; -const float LAYOUT_HORIZONTAL_SPACING = 6.0f; const float MIN_RANGE_SLIDER_WIDTH = 40.0f; bool LogSlider::draw(const char *label, float width) { @@ -161,52 +160,44 @@ void ChartsWidget::setIsDocked(bool docked) { } void ChartsWidget::drawToolBar() { - beginToolbar(); float slider_width = 150.0f; const bool is_zoomed = can->timeRange().has_value(); // the labels are captured by reference, they outlive the draw calls below std::vector items; - items.push_back({toolbarButtonWidth(icon::PLUS_SQUARE), [this]() { - if (toolButton("new_plot_btn", icon::PLUS_SQUARE, "New Chart")) newChart(); + items.push_back({iconButtonWidth(), [this]() { + if (iconButton("new_plot_btn", icon::PLUS_LG, "New Chart")) newChart(); }}); - items.push_back({toolbarButtonWidth(icon::WINDOW_STACK), [this]() { - if (toolButton("new_tab_btn", icon::WINDOW_STACK, "New Tab")) newTab(); + items.push_back({iconButtonWidth(), [this]() { + if (iconButton("new_tab_btn", icon::WINDOW_PLUS, "New Tab")) newTab(); }}); + items.back().tight = true; const std::string title_label = "Charts: " + std::to_string(charts_.size()); - items.push_back({ImGui::CalcTextSize(title_label.c_str()).x + LAYOUT_HORIZONTAL_SPACING, [&title_label]() { + items.push_back({ImGui::CalcTextSize(title_label.c_str()).x, [&title_label]() { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(title_label.c_str()); - ImGui::SameLine(0.0f, LAYOUT_HORIZONTAL_SPACING); - ImGui::Dummy(ImVec2(0.0f, 0.0f)); }}); const int type_count = (int)std::size(SERIES_TYPE_NAMES); const std::string chart_type_text = std::string("Type: ") + SERIES_TYPE_NAMES[std::clamp(settings.chart_series_type, 0, type_count - 1)]; - items.push_back({menuButtonWidth(chart_type_text), [this, &chart_type_text]() { - menuButton("chart_type", chart_type_text, "chart_type_menu"); - if (ImGui::BeginPopup("chart_type_menu")) { - for (int i = 0; i < type_count; ++i) { - if (ImGui::MenuItem(SERIES_TYPE_NAMES[i])) { - settings.chart_series_type = i; - settingChanged(); - } + auto chart_type_items = [this]() { + for (int i = 0; i < type_count; ++i) { + if (ImGui::MenuItem(SERIES_TYPE_NAMES[i], nullptr, settings.chart_series_type == i)) { + settings.chart_series_type = i; + settingChanged(); } - ImGui::EndPopup(); } - }}); + }; + items.push_back(toolbarMenu("chart_type", chart_type_text, "Type", chart_type_items)); const std::string columns_action_text = "Columns: " + std::to_string(column_count_); if (columns_action_visible_) { - items.push_back({menuButtonWidth(columns_action_text), [this, &columns_action_text]() { - menuButton("columns", columns_action_text, "columns_menu"); - if (ImGui::BeginPopup("columns_menu")) { - for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { - if (ImGui::MenuItem(std::to_string(i + 1).c_str())) setColumnCount(i + 1); - } - ImGui::EndPopup(); + auto column_items = [this]() { + for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { + if (ImGui::MenuItem(std::to_string(i + 1).c_str(), nullptr, column_count_ == i + 1)) setColumnCount(i + 1); } - }}); + }; + items.push_back(toolbarMenu("columns", columns_action_text, "Columns", column_items)); } // the spacer right aligns the rest @@ -221,36 +212,35 @@ void ChartsWidget::drawToolBar() { }}); slider_index = items.size(); items.push_back({slider_width, [this, &slider_width]() { - if (range_slider_.draw("##range_slider", slider_width)) setMaxChartRange(range_slider_.value()); + // Restore the slider width in overflow; the toolbar may have shrunk it. + const bool in_menu = ImGui::GetCurrentWindow()->Flags & ImGuiWindowFlags_Popup; + const float width = in_menu ? std::max(ImGui::GetContentRegionAvail().x, 150.0f) : slider_width; + if (range_slider_.draw("##range_slider", width)) setMaxChartRange(range_slider_.value()); ImGui::SetItemTooltip("Set the chart range"); }}); } else { char buf[64]; snprintf(buf, sizeof(buf), "%.2f-%.2f", can->timeRange()->first, can->timeRange()->second); reset_zoom_text = buf; - items.push_back({toolbarButtonWidth(icon::ARROW_COUNTERCLOCKWISE), [this]() { + items.push_back({iconButtonWidth(), [this]() { ImGui::BeginDisabled(!zoom_undo_stack_.canUndo()); - if (toolButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); + if (iconButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); ImGui::EndDisabled(); }}); - items.push_back({toolbarButtonWidth(icon::ARROW_CLOCKWISE), [this]() { + items.push_back({iconButtonWidth(), [this]() { ImGui::BeginDisabled(!zoom_undo_stack_.canRedo()); - if (toolButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); + if (iconButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); ImGui::EndDisabled(); }}); items.push_back({toolbarButtonWidth(std::string(icon::ZOOM_OUT) + " " + reset_zoom_text), [this, &reset_zoom_text]() { - if (toolButton("reset_zoom_btn", icon::ZOOM_OUT, "Reset Zoom", reset_zoom_text.c_str())) zoomReset(); + if (ImGui::Button((std::string(icon::ZOOM_OUT) + " " + reset_zoom_text + "###reset_zoom_btn").c_str())) zoomReset(); + ImGui::SetItemTooltip("Reset Zoom"); }}); } - items.push_back({toolbarButtonWidth(icon::X_SQUARE), [this]() { - ImGui::BeginDisabled(charts_.empty()); - if (toolButton("remove_all_btn", icon::X_SQUARE, "Remove all charts")) removeAll(); - ImGui::EndDisabled(); - }}); - const char *dock_btn_icon = is_docked_ ? icon::ARROW_UP_RIGHT_SQUARE : icon::ARROW_DOWN_LEFT_SQUARE; - items.push_back({toolbarButtonWidth(dock_btn_icon), [this, dock_btn_icon]() { - if (toolButton("dock_btn", dock_btn_icon, is_docked_ ? "Float the charts window" : "Dock the charts window")) toggleChartsDocking(); - }}); + items.push_back(toolbarAction("remove_all_btn", icon::TRASH, "Remove all charts", [this]() { removeAll(); }, !charts_.empty())); + const char *dock_btn_icon = is_docked_ ? icon::BOX_ARROW_UP_RIGHT : icon::BOX_ARROW_IN_DOWN_LEFT; + const char *dock_label = is_docked_ ? "Float the charts window" : "Dock the charts window"; + items.push_back(toolbarAction("dock_btn", dock_btn_icon, dock_label, [this]() { toggleChartsDocking(); }, true, true)); // the slider shrinks first, the buttons stay pinned to the right edge if (slider_index != (size_t)-1) { @@ -261,7 +251,6 @@ void ChartsWidget::drawToolBar() { } } drawToolbar(items, spacer_index); - endToolbar(); } void ChartsWidget::settingChanged() { @@ -619,7 +608,8 @@ void ChartsWidget::draw() { void ChartsContainer::draw() { ImGuiWindow *window = ImGui::GetCurrentWindow(); const ImVec2 start = ImGui::GetCursorScreenPos(); - geometry_ = ImRect(start, start + ImVec2(window->InnerRect.GetWidth(), 0)); + const float width_avail = window->InnerRect.GetWidth() - (window->ScrollbarY ? ImGui::GetStyle().ItemInnerSpacing.x : 0.0f); + geometry_ = ImRect(start, start + ImVec2(width_avail, 0)); charts_widget_->updateLayout(); const int n = std::max(charts_widget_->current_column_count_, 1); @@ -653,7 +643,7 @@ void ChartsContainer::drawDropIndicator() { r.Max.y = r.Min.y + h; } - ImGui::GetWindowDrawList()->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_Header)); + ImGui::GetWindowDrawList()->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_Header), ImGui::GetStyle().FrameRounding); } } diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc index f0f427222e..cab1de0816 100644 --- a/openpilot/tools/cabana/ui/chart/signalselector.cc +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -32,7 +32,7 @@ bool SignalSelector::draw() { return false; } - const float btn_w = ImGui::GetFrameHeight() + 8.0f; + const float btn_w = iconButtonWidth(); const float column_w = (ImGui::GetContentRegionAvail().x - btn_w - ImGui::GetStyle().ItemSpacing.x * 2) / 2; // the selected list spans the combo row too; both lists end above the Ok/Cancel row const float lists_h = ImGui::GetContentRegionAvail().y - ImGui::GetFrameHeightWithSpacing() * 3; @@ -67,10 +67,10 @@ bool SignalSelector::draw() { ImGui::BeginGroup(); ImGui::Dummy(ImVec2(btn_w, (lists_h + ImGui::GetFrameHeightWithSpacing() * 2) / 2 - ImGui::GetFrameHeight())); ImGui::BeginDisabled(available_row_ == -1); - bool add_clicked = ImGui::Button(icon::CHEVRON_RIGHT, ImVec2(btn_w, 0)); + bool add_clicked = iconButton("add", icon::CHEVRON_RIGHT, "Add"); ImGui::EndDisabled(); ImGui::BeginDisabled(selected_row_ == -1); - bool remove_clicked = ImGui::Button(icon::CHEVRON_LEFT, ImVec2(btn_w, 0)); + bool remove_clicked = iconButton("remove", icon::CHEVRON_LEFT, "Remove"); ImGui::EndDisabled(); ImGui::EndGroup(); diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.cc b/openpilot/tools/cabana/ui/chart/tiplabel.cc index 896abb7479..68889b7886 100644 --- a/openpilot/tools/cabana/ui/chart/tiplabel.cc +++ b/openpilot/tools/cabana/ui/chart/tiplabel.cc @@ -59,11 +59,8 @@ void TipLabel::draw() { if (!visible_) return; ImDrawList *p = ImGui::GetForegroundDrawList(); - const bool dark = isDarkTheme(); - const ImU32 bg = dark ? ImGui::GetColorU32(ImGuiCol_PopupBg) : ImGui::GetColorU32(ImGuiCol_ChildBg); - const ImU32 fg = dark ? ImGui::GetColorU32(ImGuiCol_Text) : IM_COL32(0x40, 0x40, 0x44, 0xff); // filled panel with a 1px frame - p->AddRectFilled(pos_, pos_ + size_, bg); - p->AddRect(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_Border)); - layoutLines(p, pos_ + ImVec2(MARGIN, MARGIN), fg); + p->AddRectFilled(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_PopupBg), ImGui::GetStyle().PopupRounding); + p->AddRect(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_Border), ImGui::GetStyle().PopupRounding); + layoutLines(p, pos_ + ImVec2(MARGIN, MARGIN), ImGui::GetColorU32(ImGuiCol_Text)); } diff --git a/openpilot/tools/cabana/ui/helpoverlay.cc b/openpilot/tools/cabana/ui/helpoverlay.cc index 5ae3dab6f6..1fe061a382 100644 --- a/openpilot/tools/cabana/ui/helpoverlay.cc +++ b/openpilot/tools/cabana/ui/helpoverlay.cc @@ -170,9 +170,8 @@ void HelpOverlay::draw() { if (!work_rect.Contains(center)) continue; // a torn off panel is in another viewport const ImVec2 min(center.x - size.x * 0.5f - 8.0f, center.y - size.y * 0.5f - 8.0f); const ImVec2 max(center.x + size.x * 0.5f + 8.0f, center.y + size.y * 0.5f + 8.0f); - // pale yellow in the light theme - const ImU32 tooltip_base = isDarkTheme() ? ImGui::GetColorU32(ImGuiCol_PopupBg) : IM_COL32(255, 255, 220, 255); - dl->AddRectFilled(min, max, tooltip_base); + dl->AddRectFilled(min, max, ImGui::GetColorU32(ImGuiCol_PopupBg), ImGui::GetStyle().PopupRounding); + dl->AddRect(min, max, ImGui::GetColorU32(ImGuiCol_Border), ImGui::GetStyle().PopupRounding); float y = min.y + 8.0f; for (const auto &line : lines) { float x = min.x + 8.0f; @@ -182,7 +181,7 @@ void HelpOverlay::draw() { if (r.swatch) { dl->AddRectFilled(ImVec2(x + 2, y + 3), ImVec2(x + font_size - 2, y + font_size - 1), color); } else { - if (r.chip) dl->AddRectFilled(ImVec2(x, y), ImVec2(x + w, y + font_size), IM_COL32(211, 211, 211, 255)); // lightGray + if (r.chip) dl->AddRectFilled(ImVec2(x, y), ImVec2(x + w, y + font_size), ImGui::GetColorU32(ImGuiCol_Button), 3.0f); dl->AddText(r.bold ? bold_font : font, font_size, ImVec2(x, y), color, r.text.c_str()); } x += w; diff --git a/openpilot/tools/cabana/ui/icons.h b/openpilot/tools/cabana/ui/icons.h index 7a61d02c09..89b8300862 100644 --- a/openpilot/tools/cabana/ui/icons.h +++ b/openpilot/tools/cabana/ui/icons.h @@ -1,42 +1,40 @@ #pragma once -// bootstrap icon glyphs, merged into the fonts by style.cc +// Bootstrap Icons codepoints; loadFonts() merges the icon font into each text font. namespace icon { constexpr const char ARROW_CLOCKWISE[] = "\xef\x84\x96"; constexpr const char ARROW_COUNTERCLOCKWISE[] = "\xef\x84\x97"; -constexpr const char ARROW_DOWN_LEFT_SQUARE[] = "\xef\x84\x9c"; -constexpr const char ARROW_UP_RIGHT_SQUARE[] = "\xef\x85\x82"; constexpr const char CHEVRON_LEFT[] = "\xef\x8a\x84"; constexpr const char CHEVRON_RIGHT[] = "\xef\x8a\x85"; constexpr const char ASPECT_RATIO[] = "\xef\x85\x90"; constexpr const char ASPECT_RATIO_FILL[] = "\xef\x85\x8f"; -constexpr const char DASH[] = "\xef\x8b\xaa"; -constexpr const char DASH_SQUARE[] = "\xef\x8b\xa8"; constexpr const char EXCLAMATION_TRIANGLE[] = "\xef\x8c\xba"; constexpr const char FAST_FORWARD[] = "\xef\x9f\xb3"; constexpr const char FILETYPE_CSV[] = "\xef\x9d\x83"; constexpr const char FOLDER[] = "\xef\x8f\x91"; constexpr const char FILE_EARMARK[] = "\xef\x8d\xa9"; constexpr const char FILE_EARMARK_RULED[] = "\xef\x8e\x84"; -constexpr const char PLUS_SQUARE[] = "\xef\x93\xbc"; constexpr const char GRAPH_UP[] = "\xef\x8f\xb2"; constexpr const char GRIP_HORIZONTAL[] = "\xef\x8f\xbd"; constexpr const char INFO_CIRCLE[] = "\xef\x90\xb0"; -constexpr const char LIST[] = "\xef\x91\xb9"; constexpr const char PAUSE[] = "\xef\x93\x83"; constexpr const char PENCIL[] = "\xef\x93\x8b"; constexpr const char PLAY[] = "\xef\x93\xb4"; -constexpr const char PLUS[] = "\xef\x93\xbe"; -constexpr const char RAQUO[] = "\xc2\xbb"; // U+00BB, not a bootstrap icon: the toolbar extension button constexpr const char REPEAT[] = "\xef\xa0\x93"; constexpr const char REPEAT_1[] = "\xef\xa0\x92"; constexpr const char REWIND[] = "\xef\xa0\x98"; constexpr const char SKIP_END[] = "\xef\x95\x97"; constexpr const char STOPWATCH[] = "\xef\x96\x96"; constexpr const char THREE_DOTS[] = "\xef\x97\x94"; -constexpr const char WINDOW_STACK[] = "\xef\x9b\x92"; -constexpr const char X[] = "\xef\x98\xaa"; constexpr const char X_LG[] = "\xef\x99\x99"; -constexpr const char X_SQUARE[] = "\xef\x98\xa8"; constexpr const char ZOOM_OUT[] = "\xef\x98\xad"; +constexpr const char PLUS_LG[] = "\xef\x99\x8d"; +constexpr const char DASH_LG[] = "\xef\x98\xbb"; +constexpr const char TRASH[] = "\xef\x9e\x8b"; +constexpr const char ARROWS_COLLAPSE[] = "\xef\x85\x8b"; +constexpr const char BOX_ARROW_UP_RIGHT[] = "\xef\x87\x85"; +constexpr const char BOX_ARROW_IN_DOWN_LEFT[] = "\xef\x86\xba"; +constexpr const char WINDOW_PLUS[] = "\xef\x9b\x90"; +constexpr const char CHEVRON_DOUBLE_RIGHT[] = "\xef\x8a\x80"; +constexpr const char THREE_DOTS_VERTICAL[] = "\xef\x97\x93"; } // namespace icon diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index a2969defcc..e5490432a2 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -132,14 +132,46 @@ void MainWindow::drawFileMenu() { if (ImGui::MenuItem("Exit", "Ctrl+Q")) close(); } +namespace { +bool beginTopMenu(const char *label, bool enabled = true) { + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImGui::GetColorU32(ImGuiCol_Header)); + const bool open = ImGui::BeginMenu(label, enabled); + ImGui::PopStyleColor(); + if (open) { + // Erase the popup's rounded top border so it joins the menu bar's separator. + const ImGuiStyle &style = ImGui::GetStyle(); + const ImGuiWindow *w = ImGui::GetCurrentWindow(); + const float r = style.PopupRounding, b = style.PopupBorderSize; + const ImVec2 min = w->Pos, max(w->Pos.x + w->Size.x, w->Pos.y + w->Size.y); + const ImU32 bg = ImGui::GetColorU32(ImGuiCol_PopupBg), border = ImGui::GetColorU32(ImGuiCol_Border); + ImDrawList *dl = w->DrawList; + dl->PushClipRect(min, max, false); // the window's own clip rect excludes its border + dl->AddRectFilled(min, ImVec2(max.x, min.y + r), bg); + dl->AddRectFilled(ImVec2(min.x, min.y), ImVec2(min.x + b, min.y + r), border); + dl->AddRectFilled(ImVec2(max.x - b, min.y), ImVec2(max.x, min.y + r), border); + dl->PopClipRect(); + } + return open; +} +} // namespace + void MainWindow::drawMenuBar() { - if (!ImGui::BeginMainMenuBar()) return; - if (ImGui::BeginMenu("File")) { + // Avoid a double border with the separator drawn below. + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + const bool open = ImGui::BeginMainMenuBar(); + ImGui::PopStyleVar(); + if (!open) return; + { + const ImVec2 min = ImGui::GetWindowPos(); + const ImVec2 max(min.x + ImGui::GetWindowWidth(), min.y + ImGui::GetWindowHeight()); + ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(min.x, max.y - 1.0f), max, ImGui::GetColorU32(ImGuiCol_Border)); + } + if (beginTopMenu("File")) { drawFileMenu(); ImGui::EndMenu(); } - if (ImGui::BeginMenu("Edit")) { + if (beginTopMenu("Edit")) { auto stack = UndoStack::instance(); const std::string undo_text = stack->canUndo() ? "Undo " + stack->undoText() : "Undo"; const std::string redo_text = stack->canRedo() ? "Redo " + stack->redoText() : "Redo"; @@ -148,7 +180,7 @@ void MainWindow::drawMenuBar() { ImGui::EndMenu(); } - if (ImGui::BeginMenu("View")) { + if (beginTopMenu("View")) { if (ImGui::MenuItem("Full Screen", "Ctrl+F11")) toggleFullScreen(); ImGui::Separator(); ImGui::MenuItem(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); @@ -162,13 +194,13 @@ void MainWindow::drawMenuBar() { ImGui::EndMenu(); } - if (ImGui::BeginMenu("Tools", hasStream())) { + if (beginTopMenu("Tools", hasStream())) { if (ImGui::MenuItem("Find Similar Bits")) findSimilarBits(); if (ImGui::MenuItem("Find Signal")) findSignal(); ImGui::EndMenu(); } - if (ImGui::BeginMenu("Help")) { + if (beginTopMenu("Help")) { if (ImGui::MenuItem("Help", "F1")) toggleHelp(); ImGui::EndMenu(); } @@ -710,6 +742,8 @@ void MainWindow::handleShortcuts() { void MainWindow::drawStatusBar() { ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::GetStyle().Colors[ImGuiCol_MenuBarBg]); ImGui::BeginChild("status_bar", ImVec2(0, ImGui::GetFrameHeight()), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar); + const ImVec2 min = ImGui::GetWindowPos(); + ImGui::GetWindowDrawList()->AddRectFilled(min, ImVec2(min.x + ImGui::GetWindowWidth(), min.y + 1.0f), ImGui::GetColorU32(ImGuiCol_Border)); // a borderless child gets no WindowPadding, so both ends sit flush against the edge and clip. Inset by // WindowPadding.x, which lines the text up with the content of the docked panels above (the messages table). const float width = ImGui::GetContentRegionAvail().x; @@ -771,6 +805,8 @@ void MainWindow::drawDockspace() { // the status bar sits below the dockspace: reserve its height plus the item spacing between the two, // otherwise the host window is a few pixels taller than the viewport and scrolls const float status_height = full_screen_ ? 0.0f : ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y; + const float top_gap = full_screen_ ? 0.0f : ImGui::GetStyle().ItemSpacing.y; + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + top_gap); const ImVec2 dock_size(ImGui::GetContentRegionAvail().x, ImGui::GetContentRegionAvail().y - status_height); const ImGuiID dock_id = ImGui::GetID("cabana_dockspace"); if (reset_layout_ || ImGui::DockBuilderGetNode(dock_id) == nullptr) { @@ -810,12 +846,19 @@ void setNextPanelClass() { window_class.DockNodeFlagsOverrideSet = ImGuiDockNodeFlags_NoWindowMenuButton; ImGui::SetNextWindowClass(&window_class); } + +bool beginPanel(const char *name, bool *open, ImGuiWindowFlags flags = 0) { + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + const bool visible = ImGui::Begin(name, open, flags); + ImGui::PopStyleVar(); + return visible; +} } // namespace void MainWindow::drawMessagesPanel() { const std::string name = messages_widget_->title() + MESSAGES_PANEL_ID; setNextPanelClass(); - if (ImGui::Begin(name.c_str(), &messages_visible_)) { + if (beginPanel(name.c_str(), &messages_visible_)) { help_overlay_.add(messages_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); messages_widget_->draw(); } @@ -827,7 +870,7 @@ void MainWindow::drawMessagesPanel() { void MainWindow::drawVideoPanel() { const std::string name = video_dock_title_ + VIDEO_PANEL; setNextPanelClass(); - const bool video_open = ImGui::Begin(name.c_str(), &video_visible_); + const bool video_open = beginPanel(name.c_str(), &video_visible_); const bool floating = floatingOut(); if (!video_open) { video_widget_->setVisible(false); // the dock is collapsed or tabbed behind another one, like hideEvent @@ -841,11 +884,23 @@ void MainWindow::drawVideoPanel() { const float video_hint = video_splitter_ratio_ >= 0.0f ? avail.y * video_splitter_ratio_ : default_h; float video_h = charts_floating_ ? avail.y : std::clamp(video_hint, 0.0f, avail.y - 1.0f); if (live) video_h = default_h; // display video at minimum size. - // dragging below half of the minimum size collapses the video, it never shrinks below it otherwise + // Collapse panes below half their minimum height to keep partially clipped controls out of view. + bool charts_collapsed = false; + const float splitter_h = ImGui::GetStyle().WindowPadding.x * 2.0f + 2.0f; if (!charts_floating_ && !live) { const float min_h = std::min(video_widget_->sizeHintHeight() + video_padding, avail.y - 1.0f); video_h = video_h < min_h / 2 ? 0.0f : std::max(video_h, min_h); + const float charts_min_h = ImGui::GetFrameHeight() + video_padding + ImGui::GetStyle().ChildBorderSize * 2.0f; + const float charts_h = avail.y - video_h - splitter_h; + if (charts_h < charts_min_h / 2) { + charts_collapsed = true; + video_h = avail.y - splitter_h; + } else if (charts_h < charts_min_h) { + video_h = avail.y - splitter_h - charts_min_h; + } } + // The splitter provides the gap; extra ItemSpacing would leave an undraggable strip. + if (!charts_floating_) ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); if (video_h > 0.0f) { ImGui::BeginChild("video", ImVec2(0, video_h), ImGuiChildFlags_Borders); help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); @@ -855,21 +910,26 @@ void MainWindow::drawVideoPanel() { video_widget_->setVisible(false); // the splitter collapsed the video: stop the vipc thread } if (!charts_floating_) { - // the gap between the video and the charts is the same as the padding at the sides - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); - ImGui::InvisibleButton("##splitter", ImVec2(-1.0f, ImGui::GetStyle().WindowPadding.x)); - if (ImGui::IsItemActive() && !live) { + ImGui::InvisibleButton("##splitter", ImVec2(-1.0f, splitter_h)); + const bool splitter_hovered = ImGui::IsItemHovered() && !live, splitter_active = ImGui::IsItemActive() && !live; + if (splitter_active) { // the size of the video is the position of the handle inside the splitter const float top = ImGui::GetWindowPos().y + ImGui::GetCursorStartPos().y; video_splitter_ratio_ = std::clamp((ImGui::GetMousePos().y - top) / avail.y, 0.0f, 1.0f); } - if (ImGui::IsItemHovered() && !live) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); - // the chart list scrolls in its own child, the container itself never scrolls - ImGui::BeginChild("charts", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + if (splitter_hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); + const ImRect splitter(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + const float line_y = std::floor(splitter.GetCenter().y) - 1.0f; + ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(splitter.Min.x, line_y), ImVec2(splitter.Max.x, line_y + 2.0f), + ImGui::GetColorU32(splitter_active ? ImGuiCol_SeparatorActive : splitter_hovered ? ImGuiCol_SeparatorHovered : ImGuiCol_Border)); ImGui::PopStyleVar(); - help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); - charts_widget_->draw(); - ImGui::EndChild(); + if (!charts_collapsed) { + // the chart list scrolls in its own child, the container itself never scrolls + ImGui::BeginChild("charts", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + charts_widget_->draw(); + ImGui::EndChild(); + } } } ImGui::End(); @@ -893,11 +953,13 @@ void MainWindow::draw() { drawDockspace(); // the central widget has no scrollbars of its own (the views inside scroll) - if (ImGui::Begin(CENTER_PANEL, nullptr, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + if (beginPanel(CENTER_PANEL, nullptr, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + ImGui::BeginChild("center", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); center_widget_.draw(); if (auto *detail = center_widget_.getDetailWidget(); detail && help_overlay_.visible()) { for (const auto &[text, rect] : detail->helpRects()) help_overlay_.add(text, rect); } + ImGui::EndChild(); } ImGui::End(); if (messages_widget_ && messages_visible_) drawMessagesPanel(); diff --git a/openpilot/tools/cabana/ui/style.cc b/openpilot/tools/cabana/ui/style.cc deleted file mode 100644 index a006dbc66d..0000000000 --- a/openpilot/tools/cabana/ui/style.cc +++ /dev/null @@ -1,284 +0,0 @@ -#include "tools/cabana/ui/app.h" - -#include -#include -#include - -#include "implot.h" -#include "tools/cabana/core/settings.h" -#include "tools/cabana/settings.h" -#include "tools/cabana/ui/util.h" -#include "tools/cabana/utils/util.h" - -namespace fs = std::filesystem; - -namespace { -bool g_dark = false; -ImFont *g_ui_font = nullptr; -ImFont *g_bold_font = nullptr; -ImFont *g_mono_font = nullptr; -ImFont *g_large_font = nullptr; - -void addIconFont(float size, ImFont *base) { - ImFontConfig cfg; - cfg.MergeMode = base != nullptr; - cfg.GlyphMinAdvanceX = size; - if (base != nullptr) { - ImFontBaked *baked = base->GetFontBaked(size); - const float center = baked != nullptr ? (baked->Ascent + baked->Descent) * 0.5f : size * 0.5f; - cfg.GlyphOffset.y = std::round(size * 0.5f - center); - } - static const ImWchar ranges[] = {0xF000, 0xF8FF, 0}; - ImGui::GetIO().Fonts->AddFontFromFileTTF(BOOTSTRAP_ICONS_TTF, size, &cfg, ranges); -} - -ImFont *addFont(const fs::path &path, float size) { - ImFontConfig cfg; - cfg.OversampleH = 2; - cfg.OversampleV = 2; - ImFont *font = ImGui::GetIO().Fonts->AddFontFromFileTTF(path.c_str(), size, &cfg); - if (font != nullptr) addIconFont(size, font); - return font; -} -} // namespace - -void loadFonts() { - ImGuiIO &io = ImGui::GetIO(); - const fs::path fonts = fs::path(CABANA_FONTS_DIR); - g_ui_font = addFont(fonts / "Inter-Regular.ttf", 16.0f); - g_bold_font = addFont(fonts / "Inter-SemiBold.ttf", 16.0f); - g_mono_font = addFont(fonts / "JetBrainsMono-Medium.ttf", 15.0f); - g_large_font = addFont(fonts / "Inter-Bold.ttf", 50.0f); - if (g_ui_font != nullptr) io.FontDefault = g_ui_font; - if (g_bold_font == nullptr) g_bold_font = g_ui_font; - if (g_mono_font == nullptr) g_mono_font = g_ui_font; - if (g_large_font == nullptr) g_large_font = g_bold_font; -} - -void applyTheme(int theme) { - const bool dark = theme == DARK_THEME; - g_dark = dark; - if (dark) { - ImGui::StyleColorsDark(); - ImPlot::StyleColorsDark(); - } else { - ImGui::StyleColorsLight(); - ImPlot::StyleColorsLight(); - } - - ImGuiStyle &style = ImGui::GetStyle(); - style.WindowRounding = 0.0f; - style.ChildRounding = 0.0f; - style.PopupRounding = 0.0f; - style.FrameRounding = 2.0f; - style.GrabRounding = 2.0f; - style.ScrollbarRounding = 2.0f; - style.TabRounding = 2.0f; - style.WindowBorderSize = 1.0f; - style.FrameBorderSize = 1.0f; - style.TabBorderSize = 1.0f; - style.WindowPadding = ImVec2(8.0f, 7.0f); - style.FramePadding = ImVec2(6.0f, 3.0f); - style.ItemSpacing = ImVec2(8.0f, 5.0f); - style.ScrollbarSize = 14.0f; - style.GrabMinSize = 13.0f; - - auto c = [](const CabanaColor &col, float a = 1.0f) { return colorRgb(col.r, col.g, col.b, a); }; - ImVec4 *colors = style.Colors; - if (dark) { - const ImVec4 highlight = c(DarkTheme::highlight); - const ImVec4 outline = colorRgb(0x26, 0x26, 0x26); - colors[ImGuiCol_WindowBg] = c(DarkTheme::window); - colors[ImGuiCol_ChildBg] = c(DarkTheme::base); - colors[ImGuiCol_PopupBg] = c(DarkTheme::window); - colors[ImGuiCol_MenuBarBg] = c(DarkTheme::window); - colors[ImGuiCol_DockingEmptyBg] = c(DarkTheme::window); - colors[ImGuiCol_Text] = c(DarkTheme::text); - colors[ImGuiCol_TextDisabled] = c(DarkTheme::disabled_text); - colors[ImGuiCol_Border] = outline; - colors[ImGuiCol_BorderShadow] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_FrameBg] = c(DarkTheme::base); - colors[ImGuiCol_FrameBgHovered] = colorRgb(0x1f, 0x1f, 0x1f); - colors[ImGuiCol_FrameBgActive] = colorRgb(0x24, 0x24, 0x24); - colors[ImGuiCol_Button] = colorRgb(0x5e, 0x5e, 0x5e); - colors[ImGuiCol_ButtonHovered] = colorRgb(0x6a, 0x6a, 0x6a); - colors[ImGuiCol_ButtonActive] = colorRgb(0x52, 0x52, 0x52); - colors[ImGuiCol_Header] = highlight; - colors[ImGuiCol_HeaderHovered] = c(DarkTheme::highlight, 0.8f); - colors[ImGuiCol_HeaderActive] = highlight; - colors[ImGuiCol_CheckMark] = c(DarkTheme::bright_text); - colors[ImGuiCol_SliderGrab] = colorRgb(0x6a, 0x6a, 0x6a); - colors[ImGuiCol_SliderGrabActive] = colorRgb(0x80, 0x80, 0x80); - colors[ImGuiCol_ScrollbarBg] = c(DarkTheme::window); - colors[ImGuiCol_ScrollbarGrab] = colorRgb(0x5a, 0x5a, 0x5a); - colors[ImGuiCol_ScrollbarGrabHovered] = colorRgb(0x6a, 0x6a, 0x6a); - colors[ImGuiCol_ScrollbarGrabActive] = colorRgb(0x7a, 0x7a, 0x7a); - colors[ImGuiCol_Separator] = outline; - colors[ImGuiCol_SeparatorHovered] = c(DarkTheme::highlight, 0.6f); - colors[ImGuiCol_SeparatorActive] = highlight; - colors[ImGuiCol_ResizeGrip] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_ResizeGripHovered] = c(DarkTheme::highlight, 0.6f); - colors[ImGuiCol_ResizeGripActive] = highlight; - colors[ImGuiCol_Tab] = colorRgb(0x2c, 0x2c, 0x2c); - colors[ImGuiCol_TabHovered] = colorRgb(0x3a, 0x3a, 0x3a); - colors[ImGuiCol_TabSelected] = c(DarkTheme::base); - colors[ImGuiCol_TabSelectedOverline] = highlight; - colors[ImGuiCol_TabDimmed] = colorRgb(0x2c, 0x2c, 0x2c); - colors[ImGuiCol_TabDimmedSelected] = c(DarkTheme::base); - colors[ImGuiCol_TabDimmedSelectedOverline] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_TitleBg] = c(DarkTheme::window); - colors[ImGuiCol_TitleBgActive] = c(DarkTheme::window); - colors[ImGuiCol_TitleBgCollapsed] = c(DarkTheme::window); - colors[ImGuiCol_TableHeaderBg] = c(DarkTheme::window); - colors[ImGuiCol_TableBorderStrong] = outline; - colors[ImGuiCol_TableBorderLight] = colorRgb(0x2c, 0x2c, 0x2c); - colors[ImGuiCol_TableRowBg] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_TableRowBgAlt] = colorRgb(0xff, 0xff, 0xff, 0.06f); - colors[ImGuiCol_TextSelectedBg] = c(DarkTheme::highlight, 0.6f); - colors[ImGuiCol_DockingPreview] = c(DarkTheme::highlight, 0.5f); - colors[ImGuiCol_NavCursor] = highlight; - colors[ImGuiCol_PlotLines] = c(DarkTheme::text); - colors[ImGuiCol_PlotHistogram] = highlight; - colors[ImGuiCol_DragDropTarget] = highlight; - } else { - const ImVec4 window = colorRgb(0xef, 0xef, 0xef); - const ImVec4 base = colorRgb(0xff, 0xff, 0xff); - const ImVec4 outline = colorRgb(0xab, 0xab, 0xab); - const ImVec4 highlight = colorRgb(0x30, 0x8c, 0xc6); - colors[ImGuiCol_WindowBg] = window; - colors[ImGuiCol_ChildBg] = base; - colors[ImGuiCol_PopupBg] = colorRgb(0xf8, 0xf8, 0xf8); - colors[ImGuiCol_MenuBarBg] = window; - colors[ImGuiCol_DockingEmptyBg] = window; - colors[ImGuiCol_Text] = colorRgb(0x00, 0x00, 0x00); - colors[ImGuiCol_TextDisabled] = colorRgb(0xbe, 0xbe, 0xbe); - colors[ImGuiCol_Border] = outline; - colors[ImGuiCol_BorderShadow] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_FrameBg] = base; - colors[ImGuiCol_FrameBgHovered] = colorRgb(0xf7, 0xf7, 0xf7); - colors[ImGuiCol_FrameBgActive] = colorRgb(0xef, 0xef, 0xef); - colors[ImGuiCol_Button] = colorRgb(0xe4, 0xe4, 0xe4); - colors[ImGuiCol_ButtonHovered] = colorRgb(0xec, 0xec, 0xec); - colors[ImGuiCol_ButtonActive] = colorRgb(0xd0, 0xd0, 0xd0); - colors[ImGuiCol_Header] = highlight; - colors[ImGuiCol_HeaderHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.8f); - colors[ImGuiCol_HeaderActive] = highlight; - colors[ImGuiCol_CheckMark] = colorRgb(0x3b, 0x3b, 0x3b); - colors[ImGuiCol_SliderGrab] = colorRgb(0xd8, 0xd8, 0xd8); - colors[ImGuiCol_SliderGrabActive] = colorRgb(0xc4, 0xc4, 0xc4); - colors[ImGuiCol_ScrollbarBg] = window; - colors[ImGuiCol_ScrollbarGrab] = colorRgb(0xc8, 0xc8, 0xc8); - colors[ImGuiCol_ScrollbarGrabHovered] = colorRgb(0xb4, 0xb4, 0xb4); - colors[ImGuiCol_ScrollbarGrabActive] = colorRgb(0xa0, 0xa0, 0xa0); - colors[ImGuiCol_Separator] = outline; - colors[ImGuiCol_SeparatorHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.6f); - colors[ImGuiCol_SeparatorActive] = highlight; - colors[ImGuiCol_ResizeGrip] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_ResizeGripHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.6f); - colors[ImGuiCol_ResizeGripActive] = highlight; - colors[ImGuiCol_Tab] = colorRgb(0xdc, 0xdc, 0xdc); - colors[ImGuiCol_TabHovered] = colorRgb(0xf5, 0xf5, 0xf5); - colors[ImGuiCol_TabSelected] = base; - colors[ImGuiCol_TabSelectedOverline] = highlight; - colors[ImGuiCol_TabDimmed] = colorRgb(0xdc, 0xdc, 0xdc); - colors[ImGuiCol_TabDimmedSelected] = base; - colors[ImGuiCol_TabDimmedSelectedOverline] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_TitleBg] = window; - colors[ImGuiCol_TitleBgActive] = window; - colors[ImGuiCol_TitleBgCollapsed] = window; - colors[ImGuiCol_TableHeaderBg] = colorRgb(0xf2, 0xf2, 0xf2); - colors[ImGuiCol_TableBorderStrong] = outline; - colors[ImGuiCol_TableBorderLight] = colorRgb(0xc7, 0xc7, 0xc7); - colors[ImGuiCol_TableRowBg] = colorRgb(0, 0, 0, 0.0f); - colors[ImGuiCol_TableRowBgAlt] = colorRgb(0, 0, 0, 0.03f); - colors[ImGuiCol_TextSelectedBg] = colorRgb(0x30, 0x8c, 0xc6, 0.35f); - colors[ImGuiCol_DockingPreview] = colorRgb(0x30, 0x8c, 0xc6, 0.5f); - colors[ImGuiCol_NavCursor] = highlight; - colors[ImGuiCol_PlotLines] = colorRgb(0x3b, 0x3b, 0x3b); - colors[ImGuiCol_PlotHistogram] = highlight; - colors[ImGuiCol_DragDropTarget] = highlight; - } - // imgui fades the modal dim in over several frames, which reads as the dialog lagging - colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0, 0, 0, 0); - colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0, 0, 0, 0); -} - -bool isDarkTheme() { return g_dark; } - -CabanaColor signalFillColor(const CabanaColor &c) { - if (!g_dark) return c; - auto [h, s, v] = c.hsv(); - return CabanaColor::fromHsv(h, std::min(1.0f, s * 1.4f), v * 0.8f, c.a / 255.0f); -} - -ImU32 highlightedTextColor() { - return g_dark ? IM_COL32(DarkTheme::window_text.r, DarkTheme::window_text.g, DarkTheme::window_text.b, 255) - : IM_COL32(255, 255, 255, 255); -} - -ImU32 paletteBrightText() { - return g_dark ? IM_COL32(DarkTheme::bright_text.r, DarkTheme::bright_text.g, DarkTheme::bright_text.b, 255) - : IM_COL32(255, 255, 255, 255); -} - -void drawSliderHandle(ImDrawList *p, const ImRect &r) { - const bool dark = isDarkTheme(); - const ImU32 top = dark ? IM_COL32(0x41, 0x41, 0x41, 255) : IM_COL32(255, 255, 255, 255); - const ImU32 bottom = dark ? IM_COL32(0x36, 0x36, 0x36, 255) : IM_COL32(0xf0, 0xf0, 0xf0, 255); - // the top/left edge is one step lighter than the bottom/right edge - const ImU32 outline_top = dark ? IM_COL32(0x5c, 0x5c, 0x5c, 255) : IM_COL32(0xab, 0xab, 0xab, 255); - const ImU32 outline_bottom = dark ? IM_COL32(0x26, 0x26, 0x26, 255) : IM_COL32(0xa4, 0xa4, 0xa4, 255); - p->AddRectFilled(r.Min, r.Max, top, 2.0f); - p->AddRectFilled(ImVec2(r.Min.x, r.GetCenter().y), r.Max, bottom, 2.0f, ImDrawFlags_RoundCornersBottom); - p->AddRect(r.Min, r.Max, outline_bottom, 2.0f, 0, 1.0f); - // the straight edges are drawn as crisp 1 px rects: an antialiased outline washes out to a much lighter grey - const float c = 2.0f; // corner radius - p->AddRectFilled(ImVec2(r.Min.x + c, r.Min.y), ImVec2(r.Max.x - c, r.Min.y + 1.0f), outline_top); - p->AddRectFilled(ImVec2(r.Min.x, r.Min.y + c), ImVec2(r.Min.x + 1.0f, r.Max.y - c), outline_top); - p->AddRectFilled(ImVec2(r.Min.x + c, r.Max.y - 1.0f), ImVec2(r.Max.x - c, r.Max.y), outline_bottom); - p->AddRectFilled(ImVec2(r.Max.x - 1.0f, r.Min.y + c), ImVec2(r.Max.x, r.Max.y - c), outline_bottom); -} - -bool fusionSliderInt(const char *label, int *v, int min, int max, float width) { - // a grey groove over the full width with the part left of the handle filled, and a 13x13 handle on top - const ImU32 groove_col = isDarkTheme() ? IM_COL32(0x2b, 0x2b, 0x2b, 255) : IM_COL32(0xc4, 0xc4, 0xc4, 255); - const ImU32 fill_col = ImGui::GetColorU32(ImGuiCol_Header); - ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32_BLACK_TRANS); - ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32_BLACK_TRANS); - ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32_BLACK_TRANS); - ImGui::PushStyleColor(ImGuiCol_SliderGrab, IM_COL32_BLACK_TRANS); - ImGui::PushStyleColor(ImGuiCol_SliderGrabActive, IM_COL32_BLACK_TRANS); - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); // the slider has no frame - ImGui::SetNextItemWidth(width); - bool changed = ImGui::SliderInt(label, v, min, max, "", ImGuiSliderFlags_NoInput); - ImGui::PopStyleVar(); - ImGui::PopStyleColor(5); - - const ImVec2 bb_min = ImGui::GetItemRectMin(), bb_max = ImGui::GetItemRectMax(); - const float cy = (bb_min.y + bb_max.y) * 0.5f; - const float groove_h = SLIDER_THICKNESS * 0.5f; - const float handle_h = std::min(SLIDER_THICKNESS, bb_max.y - bb_min.y); - const float x0 = bb_min.x + SLIDER_LENGTH * 0.5f, x1 = bb_max.x - SLIDER_LENGTH * 0.5f; - const float t = max > min ? (float)(*v - min) / (float)(max - min) : 0.0f; - const float hx = x0 + (x1 - x0) * t; - ImDrawList *dl = ImGui::GetWindowDrawList(); - const float groove_y0 = cy - groove_h * 0.5f, groove_y1 = cy + groove_h * 0.5f; - dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(bb_max.x, groove_y1), groove_col, groove_h * 0.5f); - dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(hx, groove_y1), fill_col, groove_h * 0.5f); - drawSliderHandle(dl, ImRect(ImVec2(hx - SLIDER_LENGTH * 0.5f, cy - handle_h * 0.5f), - ImVec2(hx + SLIDER_LENGTH * 0.5f, cy + handle_h * 0.5f))); - return changed; -} - -ImFont *boldFont() { return g_bold_font; } -ImFont *monoFont() { return g_mono_font; } - -void pushMonoFont(float size) { - if (!g_mono_font) return; - size > 0.0f ? ImGui::PushFont(g_mono_font, size) : ImGui::PushFont(g_mono_font); -} -void popMonoFont() { if (g_mono_font) ImGui::PopFont(); } -void pushBoldFont() { if (g_bold_font) ImGui::PushFont(g_bold_font); } -void popBoldFont() { if (g_bold_font) ImGui::PopFont(); } -void pushLargeFont() { if (g_large_font) ImGui::PushFont(g_large_font); } -void popLargeFont() { if (g_large_font) ImGui::PopFont(); } diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc new file mode 100644 index 0000000000..ff02ca24c7 --- /dev/null +++ b/openpilot/tools/cabana/ui/theme.cc @@ -0,0 +1,174 @@ +#include "tools/cabana/ui/theme.h" + +#include +#include +#include + +#include "implot.h" +#include "tools/cabana/core/settings.h" + +namespace fs = std::filesystem; + +namespace { + +constexpr Palette DARK_PALETTE = { + .text = rgb(0xf8f9f9), .text_disabled = rgb(0xb8c0c4), + .window = rgb(0x1d2225), .surface = rgb(0x30373b), + .frame = rgb(0x1e2224), .frame_hovered = rgb(0x394044), .frame_active = rgb(0x424a4f), + .button = rgb(0x424a4f), .button_hovered = rgb(0x535f64), .button_active = rgb(0x175886), + .header = rgb(0x175886), .header_hovered = rgb(0x24455e), .header_active = rgb(0x1c6ea8), + .accent = rgb(0x57a9e3), + .border = rgb(0x65737a), .separator = rgb(0x4b5559), + .tab = rgb(0x272c2f), .tab_hovered = rgb(0x424a4f), .table_header = rgb(0x424a4f), + .grid = rgb(0x65737a, 0.45f), .badge = rgb(0x808080), +}; + +constexpr Palette LIGHT_PALETTE = { + .text = rgb(0x1e2224), .text_disabled = rgb(0x535f64), + .window = rgb(0xeeeff0), .surface = rgb(0xffffff), + .frame = rgb(0xf8f9f9), .frame_hovered = rgb(0xeeeff0), .frame_active = rgb(0xddeef9), + .button = rgb(0xe3e6e8), .button_hovered = rgb(0xd8dcdf), .button_active = rgb(0xbcddf4), + .header = rgb(0xbcddf4), .header_hovered = rgb(0xddeef9), .header_active = rgb(0x9fcbec), + .accent = rgb(0x1c6ea8), + .border = rgb(0x98a3a9), .separator = rgb(0xcdd3d6), + .tab = rgb(0xe3e6e8), .tab_hovered = rgb(0xddeef9), .table_header = rgb(0xd8dcdf), + .grid = rgb(0x98a3a9, 0.4f), .badge = rgb(0xa0a0a4), +}; + +bool g_dark = false; +const Palette *g_palette = &LIGHT_PALETTE; +ImFont *g_ui_font = nullptr; +ImFont *g_bold_font = nullptr; +ImFont *g_mono_font = nullptr; +ImFont *g_large_font = nullptr; + +void addIconFont(float size, ImFont *base) { + ImFontConfig cfg; + cfg.MergeMode = base != nullptr; + cfg.GlyphMinAdvanceX = size; + if (base != nullptr) { + ImFontBaked *baked = base->GetFontBaked(size); + const float center = baked != nullptr ? (baked->Ascent + baked->Descent) * 0.5f : size * 0.5f; + cfg.GlyphOffset.y = std::round(size * 0.5f - center); + } + static const ImWchar ranges[] = {0xF000, 0xF8FF, 0}; + ImGui::GetIO().Fonts->AddFontFromFileTTF(BOOTSTRAP_ICONS_TTF, size, &cfg, ranges); +} + +ImFont *addFont(const fs::path &path, float size) { + ImFontConfig cfg; + cfg.OversampleH = 2; + cfg.OversampleV = 2; + ImFont *font = ImGui::GetIO().Fonts->AddFontFromFileTTF(path.c_str(), size, &cfg); + if (font != nullptr) addIconFont(size, font); + return font; +} + +ImVec4 alpha(ImVec4 c, float a) { return ImVec4(c.x, c.y, c.z, a); } + +} // namespace + +void loadFonts() { + ImGuiIO &io = ImGui::GetIO(); + const fs::path fonts = fs::path(CABANA_FONTS_DIR); + g_ui_font = addFont(fonts / "Inter-Regular.ttf", UI_FONT_SIZE); + g_bold_font = addFont(fonts / "Inter-SemiBold.ttf", UI_FONT_SIZE); + g_mono_font = addFont(fonts / "JetBrainsMono-Medium.ttf", 15.0f); + g_large_font = addFont(fonts / "Inter-Bold.ttf", 50.0f); + if (g_ui_font != nullptr) io.FontDefault = g_ui_font; + if (g_bold_font == nullptr) g_bold_font = g_ui_font; + if (g_mono_font == nullptr) g_mono_font = g_ui_font; + if (g_large_font == nullptr) g_large_font = g_bold_font; +} + +void applyTheme(int theme) { + g_dark = theme == DARK_THEME; + g_palette = g_dark ? &DARK_PALETTE : &LIGHT_PALETTE; + const Palette &p = *g_palette; + const ImVec4 none(0, 0, 0, 0); + + ImGuiStyle &style = ImGui::GetStyle(); + style = ImGuiStyle(); + style.WindowRounding = 6.0f; // dialogs and tooltips; docked panels and os windows ignore it + style.ChildRounding = 6.0f; + style.PopupRounding = 6.0f; + style.FrameRounding = 4.0f; + style.GrabRounding = 4.0f; + style.ScrollbarRounding = 4.0f; + style.TabRounding = 4.0f; + style.WindowBorderSize = 1.0f; + style.FrameBorderSize = 1.0f; + style.TabBorderSize = 1.0f; + style.WindowPadding = ImVec2(12.0f, 10.0f); + style.FramePadding = ImVec2(9.0f, 5.0f); + style.ItemSpacing = ImVec2(10.0f, 8.0f); + style.CellPadding = ImVec2(6.0f, 4.0f); + style.ScrollbarSize = 14.0f; + style.GrabMinSize = 13.0f; + + ImVec4 *c = style.Colors; + c[ImGuiCol_Text] = p.text; + c[ImGuiCol_TextDisabled] = p.text_disabled; + c[ImGuiCol_WindowBg] = c[ImGuiCol_ScrollbarBg] = c[ImGuiCol_DockingEmptyBg] = p.window; + c[ImGuiCol_MenuBarBg] = p.surface; + c[ImGuiCol_TitleBg] = c[ImGuiCol_TitleBgActive] = c[ImGuiCol_TitleBgCollapsed] = p.window; + c[ImGuiCol_ChildBg] = c[ImGuiCol_PopupBg] = p.surface; + c[ImGuiCol_Border] = c[ImGuiCol_TableBorderStrong] = p.border; + c[ImGuiCol_Separator] = c[ImGuiCol_TableBorderLight] = p.separator; + c[ImGuiCol_BorderShadow] = c[ImGuiCol_ResizeGrip] = c[ImGuiCol_TableRowBg] = none; + c[ImGuiCol_FrameBg] = p.frame; + c[ImGuiCol_FrameBgHovered] = p.frame_hovered; + c[ImGuiCol_FrameBgActive] = p.frame_active; + c[ImGuiCol_Button] = p.button; + c[ImGuiCol_ButtonHovered] = c[ImGuiCol_SliderGrab] = p.button_hovered; + c[ImGuiCol_ScrollbarGrab] = p.border; + c[ImGuiCol_ScrollbarGrabHovered] = p.text_disabled; + c[ImGuiCol_ButtonActive] = p.button_active; + c[ImGuiCol_Header] = p.header; + c[ImGuiCol_HeaderHovered] = p.header_hovered; + c[ImGuiCol_HeaderActive] = p.header_active; + c[ImGuiCol_CheckMark] = c[ImGuiCol_NavCursor] = c[ImGuiCol_SliderGrabActive] = c[ImGuiCol_ScrollbarGrabActive] = p.accent; + c[ImGuiCol_SeparatorActive] = c[ImGuiCol_ResizeGripActive] = c[ImGuiCol_DragDropTarget] = p.accent; + c[ImGuiCol_TabSelectedOverline] = c[ImGuiCol_PlotHistogram] = p.accent; + c[ImGuiCol_SeparatorHovered] = c[ImGuiCol_ResizeGripHovered] = alpha(p.accent, 0.6f); + c[ImGuiCol_DockingPreview] = alpha(p.accent, 0.5f); + c[ImGuiCol_TextSelectedBg] = alpha(p.accent, 0.35f); + c[ImGuiCol_Tab] = c[ImGuiCol_TabDimmed] = p.tab; + c[ImGuiCol_TabHovered] = p.tab_hovered; + c[ImGuiCol_TabSelected] = c[ImGuiCol_TabDimmedSelected] = p.surface; + c[ImGuiCol_TabDimmedSelectedOverline] = none; + c[ImGuiCol_TableHeaderBg] = p.table_header; + c[ImGuiCol_TableRowBgAlt] = g_dark ? ImVec4(1, 1, 1, 0.065f) : ImVec4(0, 0, 0, 0.045f); + c[ImGuiCol_PlotLines] = p.text; + // ImGuiStyle() seeds every slot from the dark theme: set the rest so the light theme does not keep a white caret. + c[ImGuiCol_InputTextCursor] = c[ImGuiCol_UnsavedMarker] = p.text; + c[ImGuiCol_TextLink] = c[ImGuiCol_PlotLinesHovered] = c[ImGuiCol_PlotHistogramHovered] = c[ImGuiCol_NavWindowingHighlight] = p.accent; + c[ImGuiCol_TreeLines] = p.separator; + c[ImGuiCol_DragDropTargetBg] = alpha(p.accent, 0.2f); + // Disable the modal dim fade to make dialogs appear immediately. + c[ImGuiCol_ModalWindowDimBg] = c[ImGuiCol_NavWindowingDimBg] = none; + + // ChartView::drawAxes() pushes the other plot colors it needs; the rest are auto colors derived from the ImGui style. + ImPlot::GetStyle().Colors[ImPlotCol_AxisGrid] = p.grid; +} + +bool isDarkTheme() { return g_dark; } +const Palette &palette() { return *g_palette; } + +CabanaColor signalFillColor(const CabanaColor &c) { + if (!g_dark) return c; + auto [h, s, v] = c.hsv(); + return CabanaColor::fromHsv(h, std::min(1.0f, s * 1.4f), v * 0.8f, c.a / 255.0f); +} + +ImFont *boldFont() { return g_bold_font; } + +void pushMonoFont(float size) { + if (!g_mono_font) return; + size > 0.0f ? ImGui::PushFont(g_mono_font, size) : ImGui::PushFont(g_mono_font); +} +void popMonoFont() { if (g_mono_font) ImGui::PopFont(); } +void pushBoldFont() { if (g_bold_font) ImGui::PushFont(g_bold_font); } +void popBoldFont() { if (g_bold_font) ImGui::PopFont(); } +void pushLargeFont() { if (g_large_font) ImGui::PushFont(g_large_font); } +void popLargeFont() { if (g_large_font) ImGui::PopFont(); } diff --git a/openpilot/tools/cabana/ui/theme.h b/openpilot/tools/cabana/ui/theme.h new file mode 100644 index 0000000000..91f2a0740f --- /dev/null +++ b/openpilot/tools/cabana/ui/theme.h @@ -0,0 +1,49 @@ +#pragma once + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/core/color.h" + +// Palette source: commaai/connect src/{colors,theme}.js at 7091050. +// Dark colors follow connect; light colors use its lightGrey and lightBlue families. +struct Palette { + ImVec4 text, text_disabled; + ImVec4 window; // the background behind panels and docked windows + ImVec4 surface; // panels, popups, table bodies: what content is drawn on + ImVec4 frame, frame_hovered, frame_active; + ImVec4 button, button_hovered, button_active; + ImVec4 header, header_hovered, header_active; // selections + ImVec4 accent; + ImVec4 border, separator; + ImVec4 tab, tab_hovered, table_header; + ImVec4 grid; + ImVec4 badge; // the fill behind the time labels drawn over a chart +}; + +constexpr ImVec4 rgb(unsigned hex, float alpha = 1.0f) { + return ImVec4(((hex >> 16) & 255) / 255.0f, ((hex >> 8) & 255) / 255.0f, (hex & 255) / 255.0f, alpha); +} +inline ImVec4 colorRgb(int r, int g, int b, float alpha = 1.0f) { + return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, alpha); +} +inline ImU32 toImU32(const CabanaColor &c) { return IM_COL32(c.r, c.g, c.b, c.a); } +inline ImVec4 toImVec4(const CabanaColor &c) { return ImVec4(c.r / 255.0f, c.g / 255.0f, c.b / 255.0f, c.a / 255.0f); } +inline ImU32 withAlpha(ImU32 c, int alpha) { return (c & ~IM_COL32_A_MASK) | ((ImU32)alpha << IM_COL32_A_SHIFT); } + +constexpr float UI_FONT_SIZE = 16.0f; + +void loadFonts(); +void applyTheme(int theme); // Safe to call at runtime. +bool isDarkTheme(); +const Palette &palette(); + +CabanaColor signalFillColor(const CabanaColor &c); + +ImFont *boldFont(); +void pushMonoFont(float size = 0.0f); +void popMonoFont(); +void pushBoldFont(); +void popBoldFont(); +void pushLargeFont(); +void popLargeFont(); diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 2e538e16f1..f646f33da4 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -3,7 +3,10 @@ #include #include #include +#include +#include #include +#include #include #include "imgui.h" @@ -22,6 +25,10 @@ void objc_msgSend(void); #include "tools/cabana/ui/icons.h" +namespace { +ImU32 u32(const ImVec4 &c) { return ImGui::ColorConvertFloat4ToU32(c); } +} // namespace + int inputCallback(ImGuiInputTextCallbackData *data) { auto *ctx = static_cast(data->UserData); if (data->EventFlag == ImGuiInputTextFlags_CallbackCharFilter) { @@ -58,9 +65,9 @@ bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, I bool clearableInput(const char *label, std::string *s, const char *hint, ImGuiInputTextCallback validator) { bool changed = validatedInput(label, s, validator, hint); if (!s->empty()) { - ImGui::SameLine(0.0f, 0.0f); + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); ImGui::PushID(label); - if (toolButton("clear", icon::X)) { + if (iconButton("clear", icon::X_LG)) { s->clear(); changed = true; } @@ -128,14 +135,68 @@ int nonWhitespaceValidator(ImGuiInputTextCallbackData *data) { return (data->EventChar < 128 && std::isspace((int)data->EventChar)) ? 1 : 0; } -bool toolButton(const char *id, const char *icon, const char *tooltip, const char *text) { - std::string label = text && *text ? std::string(icon) + " " + text + "###" + id : std::string(icon) + "###" + id; - // no frame, transparent until hovered - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); - bool clicked = ImGui::Button(label.c_str()); - ImGui::PopStyleVar(); - ImGui::PopStyleColor(); +float iconButtonWidth() { return ImGui::GetFrameHeight(); } + +namespace { +constexpr float ICON_BUTTON_GLYPH_SCALE = 0.8f; + +// Exclude rasterization padding when centering icons; it varies between glyphs. +struct GlyphInk { float x0, y0, x1, y1; }; +GlyphInk glyphInk(const ImFontGlyph *g) { + ImTextureData *tex = ImGui::GetIO().Fonts->TexData; + const int px0 = (int)std::lround(g->U0 * tex->Width), px1 = (int)std::lround(g->U1 * tex->Width); + const int py0 = (int)std::lround(g->V0 * tex->Height), py1 = (int)std::lround(g->V1 * tex->Height); + int ix0 = px1, iy0 = py1, ix1 = px0, iy1 = py0; + for (int y = py0; y < py1; ++y) { + for (int x = px0; x < px1; ++x) { + const unsigned char *p = (const unsigned char *)tex->GetPixelsAt(x, y); + const unsigned char alpha = tex->Format == ImTextureFormat_Alpha8 ? p[0] : p[3]; + if (alpha < 64) continue; + ix0 = std::min(ix0, x); ix1 = std::max(ix1, x + 1); + iy0 = std::min(iy0, y); iy1 = std::max(iy1, y + 1); + } + } + if (ix0 >= ix1 || py1 <= py0 || px1 <= px0) return {g->X0, g->Y0, g->X1, g->Y1}; + const float sx = (g->X1 - g->X0) / (px1 - px0), sy = (g->Y1 - g->Y0) / (py1 - py0); + return {g->X0 + (ix0 - px0) * sx, g->Y0 + (iy0 - py0) * sy, g->X0 + (ix1 - px0) * sx, g->Y0 + (iy1 - py0) * sy}; +} + +// The scan reads the atlas pixels: do it once per icon, and again when the atlas repacked the glyph. +const GlyphInk &cachedGlyphInk(const ImFontGlyph *g, float size, unsigned int codepoint) { + struct Entry { ImVec4 uv; GlyphInk ink; }; + static std::unordered_map cache; + const uint64_t key = ((uint64_t)(uint32_t)size << 32) | codepoint; + const ImVec4 uv(g->U0, g->V0, g->U1, g->V1); + auto it = cache.find(key); + if (it == cache.end() || memcmp(&it->second.uv, &uv, sizeof(uv)) != 0) it = cache.insert_or_assign(key, Entry{uv, glyphInk(g)}).first; + return it->second.ink; +} + +bool squareIconButton(const char *id, const char *icon) { + const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(iconButtonWidth(), 0.0f)); + const ImRect r(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + unsigned int codepoint = 0; + ImTextCharFromUtf8(&codepoint, icon, nullptr); + // Leave a margin even for icons that fill the glyph bounds. + const float size = std::round(ImGui::GetFontSize() * ICON_BUTTON_GLYPH_SCALE); + ImFontBaked *baked = ImGui::GetFont()->GetFontBaked(size); + if (const ImFontGlyph *g = baked->FindGlyph((ImWchar)codepoint)) { + const GlyphInk ink = cachedGlyphInk(g, size, codepoint); + // Preserve half-logical-pixel positions on HiDPI displays. + const float snap = std::max(1.0f, ImGui::GetIO().DisplayFramebufferScale.x); + auto snapped = [snap](float v) { return std::round(v * snap) / snap; }; + const ImVec2 pos(snapped(r.GetCenter().x - (ink.x0 + ink.x1) * 0.5f), snapped(r.GetCenter().y - (ink.y0 + ink.y1) * 0.5f)); + // AddText truncates to whole logical pixels, undoing the framebuffer snapping above. + ImGui::GetWindowDrawList()->AddImage(ImGui::GetIO().Fonts->TexRef, ImVec2(pos.x + g->X0, pos.y + g->Y0), + ImVec2(pos.x + g->X1, pos.y + g->Y1), ImVec2(g->U0, g->V0), ImVec2(g->U1, g->V1), + ImGui::GetColorU32(ImGuiCol_Text)); + } + return clicked; +} +} // namespace + +bool iconButton(const char *id, const char *icon, const char *tooltip) { + const bool clicked = squareIconButton(id, icon); if (tooltip && *tooltip) ImGui::SetItemTooltip("%s", tooltip); return clicked; } @@ -240,7 +301,7 @@ bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags } bool checkBox(const char *label, bool *v) { - const float box = 16.0f; + const float box = CHECKBOX_SIZE; ImGuiWindow *window = ImGui::GetCurrentWindow(); if (window->SkipItems) return false; const ImGuiStyle &style = ImGui::GetStyle(); @@ -345,21 +406,41 @@ bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWin // tool bar -void beginToolbar() { - // the items sit next to each other, the buttons only carry the auto raise margin - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(TOOLBAR_ITEM_SPACING, ImGui::GetStyle().ItemSpacing.y)); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(TOOLBAR_BUTTON_PADDING, ImGui::GetStyle().FramePadding.y)); +ToolbarItem toolbarAction(const char *id, const char *icon, const char *label, std::function trigger, bool enabled, bool tight) { + return {iconButtonWidth(), [=]() { + ImGui::BeginDisabled(!enabled); + if (iconButton(id, icon)) trigger(); + ImGui::EndDisabled(); + disabledItemTooltip(label); + }, label, trigger, enabled, true, tight}; } -void endToolbar() { ImGui::PopStyleVar(2); } +ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *label, std::function items, bool bold, bool tight, float width) { + if (width <= 0.0f) width = menuButtonWidth(text, bold); + ToolbarItem item{width, [id, text, items, bold, width]() { + const std::string popup_id = std::string(id) + "_menu"; + menuButton(id, text, popup_id.c_str(), bold, width); + if (ImGui::BeginPopup(popup_id.c_str())) { + items(); + ImGui::EndPopup(); + } + }, label}; + item.tight = tight; + item.submenu = std::move(items); + return item; +} float toolbarButtonWidth(const std::string &label) { return ImGui::CalcTextSize(label.c_str(), nullptr, true).x + ImGui::GetStyle().FramePadding.x * 2; } +static float toolbarSpacing(const ToolbarItem &item) { + return item.tight ? ImGui::GetStyle().ItemInnerSpacing.x : ImGui::GetStyle().ItemSpacing.x; +} + static float toolbarGroupWidth(const std::vector &items, size_t begin, size_t end) { float w = 0; - for (size_t i = begin; i < end; ++i) w += items[i].width + (i > begin ? ImGui::GetStyle().ItemSpacing.x : 0); + for (size_t i = begin; i < end; ++i) w += items[i].width + (i > begin ? toolbarSpacing(items[i]) : 0); return w; } @@ -370,24 +451,25 @@ float toolbarWidth(const std::vector &items, size_t spacer_index) { return w; } -void drawToolbar(const std::vector &items, size_t spacer_index) { +void drawToolbar(const std::vector &items, size_t spacer_index, float width) { const ImGuiStyle &style = ImGui::GetStyle(); spacer_index = std::min(spacer_index, items.size()); const float right_width = toolbarGroupWidth(items, spacer_index, items.size()); const float start_x = ImGui::GetCursorPosX(); - const float avail = ImGui::GetContentRegionAvail().x; + const float avail = width < 0.0f ? ImGui::GetContentRegionAvail().x : width; const float right_edge = start_x + avail; - const float extension_width = toolbarButtonWidth(icon::RAQUO); + const float extension_width = iconButtonWidth(); // when everything fits the spacer takes the slack, otherwise the extension button is reserved at the // right edge and the items are packed from the left until the next one does not fit - const bool fits = toolbarWidth(items, spacer_index) <= avail; + // a caller may size a flexible item from the same available width: allow for the float error of the round trip + const bool fits = toolbarWidth(items, spacer_index) <= avail + 0.5f; size_t visible = items.size(); if (!fits) { const float usable = avail - (extension_width + style.ItemSpacing.x); float used = 0; for (visible = 0; visible < items.size(); ++visible) { - const float w = items[visible].width + (visible ? style.ItemSpacing.x : 0); + const float w = items[visible].width + (visible ? toolbarSpacing(items[visible]) : 0); if (used + w > usable) break; used += w; } @@ -396,7 +478,7 @@ void drawToolbar(const std::vector &items, size_t spacer_index) { for (size_t i = 0; i < visible; ++i) { if (i == 0) ImGui::SetCursorPosX(start_x); else if (fits && i == spacer_index) ImGui::SameLine(right_edge - right_width); - else ImGui::SameLine(); + else ImGui::SameLine(0.0f, toolbarSpacing(items[i])); items[i].draw(); } @@ -404,9 +486,7 @@ void drawToolbar(const std::vector &items, size_t spacer_index) { // the extension button sits fully inside the toolbar: its right edge is the content region right edge const float extension_x = std::max(start_x, right_edge - extension_width); visible == 0 ? ImGui::SetCursorPosX(extension_x) : ImGui::SameLine(extension_x); - if (ImGui::Button((std::string(icon::RAQUO) + "###toolbar_extension").c_str(), ImVec2(extension_width, 0))) - ImGui::OpenPopup("toolbar_extension_menu"); - ImGui::SetItemTooltip("More"); + if (iconButton("toolbar_extension", icon::CHEVRON_DOUBLE_RIGHT, "More")) ImGui::OpenPopup("toolbar_extension_menu"); // the popup opens inward: its right edge is aligned with the button so it stays inside the window ImGui::SetNextWindowPos(ImVec2(ImGui::GetItemRectMax().x, ImGui::GetItemRectMax().y), ImGuiCond_Always, ImVec2(1, 0)); if (ImGui::BeginPopup("toolbar_extension_menu")) { @@ -414,6 +494,11 @@ void drawToolbar(const std::vector &items, size_t spacer_index) { if (!items[i].in_menu) continue; if (items[i].menu_label.empty()) { items[i].draw(); + } else if (items[i].submenu) { + if (ImGui::BeginMenu(items[i].menu_label.c_str(), items[i].enabled)) { + items[i].submenu(); + ImGui::EndMenu(); + } } else if (ImGui::MenuItem(items[i].menu_label.c_str(), nullptr, false, items[i].enabled)) { items[i].trigger(); } @@ -438,20 +523,17 @@ bool menuButton(const char *id, const std::string &text, const char *popup_id, b const ImGuiStyle &style = ImGui::GetStyle(); const bool popup_open = ImGui::IsPopupOpen(popup_id); if (width <= 0.0f) width = menuButtonWidth(text, bold); - // no frame, transparent until hovered; the button is drawn pressed while the menu is open. The menu opens - // on press; a press while it is open toggles it closed (imgui closes the popup at the end of the frame of - // a click outside it, so only open when it is not already open) + // ImGui closes popups at frame end on outside clicks. Only open a closed popup so a second press toggles it off. if (bold) pushBoldFont(); const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; const float ascent = ImGui::GetFontBaked()->Ascent; // the text and the arrow are centered as a group in the button const float padding_x = std::max(style.FramePadding.x, (width - (text_width + MENU_ARROW_SPACING + MENU_ARROW_SIZE)) * 0.5f); - ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : ImVec4(0, 0, 0, 0)); - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : style.Colors[ImGuiCol_Button]); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(padding_x, style.FramePadding.y)); ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.0f, 0.5f)); const bool clicked = ImGui::ButtonEx((text + "###" + id).c_str(), ImVec2(width, 0.0f), ImGuiButtonFlags_PressedOnClick); - ImGui::PopStyleVar(3); + ImGui::PopStyleVar(2); ImGui::PopStyleColor(); if (bold) popBoldFont(); // a 6 px arrow right after the text, sitting on the text baseline @@ -467,3 +549,39 @@ bool menuButton(const char *id, const std::string &text, const char *popup_id, b ImGui::SetNextWindowPos(ImVec2(min.x, ImGui::GetItemRectMax().y), ImGuiCond_Always); return clicked; } + +void drawSliderHandle(ImDrawList *p, const ImRect &r) { + const Palette &pal = palette(); + p->AddRectFilled(r.Min, r.Max, u32(pal.button_hovered), 2.0f); + p->AddRectFilled(ImVec2(r.Min.x, r.GetCenter().y), r.Max, u32(pal.button), 2.0f, ImDrawFlags_RoundCornersBottom); + p->AddRect(r.Min, r.Max, u32(pal.border), 2.0f, 0, 1.0f); +} + +bool fusionSliderInt(const char *label, int *v, int min, int max, float width) { + // Keep ImGui slider input handling, but replace its frame and grab with custom drawing. + ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_SliderGrab, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_SliderGrabActive, IM_COL32_BLACK_TRANS); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::SetNextItemWidth(width); + bool changed = ImGui::SliderInt(label, v, min, max, "", ImGuiSliderFlags_NoInput); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(5); + + const ImVec2 bb_min = ImGui::GetItemRectMin(), bb_max = ImGui::GetItemRectMax(); + const float cy = (bb_min.y + bb_max.y) * 0.5f; + const float groove_h = SLIDER_THICKNESS * 0.5f; + const float handle_h = std::min(SLIDER_THICKNESS, bb_max.y - bb_min.y); + const float x0 = bb_min.x + SLIDER_LENGTH * 0.5f, x1 = bb_max.x - SLIDER_LENGTH * 0.5f; + const float t = max > min ? (float)(*v - min) / (float)(max - min) : 0.0f; + const float hx = x0 + (x1 - x0) * t; + ImDrawList *dl = ImGui::GetWindowDrawList(); + const float groove_y0 = cy - groove_h * 0.5f, groove_y1 = cy + groove_h * 0.5f; + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(bb_max.x, groove_y1), u32(palette().separator), groove_h * 0.5f); + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(hx, groove_y1), u32(palette().accent), groove_h * 0.5f); + drawSliderHandle(dl, ImRect(ImVec2(hx - SLIDER_LENGTH * 0.5f, cy - handle_h * 0.5f), + ImVec2(hx + SLIDER_LENGTH * 0.5f, cy + handle_h * 0.5f))); + return changed; +} diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index d180c073b7..87afb28f74 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -4,22 +4,11 @@ #include #include -#include "imgui.h" -#include "imgui_internal.h" - -#include "tools/cabana/core/color.h" +#include "tools/cabana/ui/theme.h" #include "tools/cabana/utils/util.h" struct GLFWwindow; -inline ImVec4 colorRgb(int r, int g, int b, float alpha = 1.0f) { - return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, alpha); -} - -inline ImU32 toImU32(const CabanaColor &c) { return IM_COL32(c.r, c.g, c.b, c.a); } -inline ImVec4 toImVec4(const CabanaColor &c) { return ImVec4(c.r / 255.0f, c.g / 255.0f, c.b / 255.0f, c.a / 255.0f); } -inline ImU32 withAlpha(ImU32 c, int alpha) { return (c & ~IM_COL32_A_MASK) | ((ImU32)alpha << IM_COL32_A_SHIFT); } - // the dock window identity of the messages panel (the visible title changes, the part after ### is the id) constexpr const char *MESSAGES_PANEL_ID = "###MessagesPanel"; @@ -78,8 +67,9 @@ int doubleValidator(ImGuiInputTextCallbackData *data); int ipValidator(ImGuiInputTextCallbackData *data); int nonWhitespaceValidator(ImGuiInputTextCallbackData *data); -// auto-raise icon button with a tooltip -bool toolButton(const char *id, const char *icon, const char *tooltip = nullptr, const char *text = nullptr); +// Use ItemInnerSpacing between related buttons and ItemSpacing between groups. +bool iconButton(const char *id, const char *icon, const char *tooltip = nullptr); +float iconButtonWidth(); // tooltip for the last item that also shows while the item is disabled void disabledItemTooltip(const char *text); @@ -121,6 +111,7 @@ bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags // a 16px box vertically centered in the frame height so rows keep their layout; ImGui::Checkbox draws a // frame height (22 px) square. bool checkBox(const char *label, bool *v); +constexpr float CHECKBOX_SIZE = 16.0f; // the next items on the line are right aligned as a block `width` wide void alignRight(float width); @@ -134,14 +125,6 @@ void drawElidedText(ImDrawList *dl, const ImRect &rect, const std::string &text, float markerSize(); void drawColorMarker(ImDrawList *dl, const ImVec2 &pos, ImU32 col); -void loadFonts(); -void applyTheme(int theme); // safe to call at runtime -bool isDarkTheme(); // the theme applyTheme() resolved -CabanaColor signalFillColor(const CabanaColor &c); - -ImU32 highlightedTextColor(); -ImU32 paletteBrightText(); - // the next window is a real OS window instead of being drawn inside the main one void setNextWindowFloatsOut(); #ifdef __APPLE__ @@ -158,8 +141,6 @@ void setNextDialogWindow(const ImVec2 &size); // centered modal dialog. false when the popup is not submitted this frame. bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize); -const float TOOLBAR_ITEM_SPACING = 1.0f; -const float TOOLBAR_BUTTON_PADDING = 4.0f; // auto raise button horizontal margin const float SLIDER_LENGTH = 13.0f; const float SLIDER_THICKNESS = 13.0f; @@ -172,31 +153,27 @@ struct ToolbarItem { std::function trigger; bool enabled = true; bool in_menu = true; // false: left out of the ">>" menu (a separator) + bool tight = false; // true: ItemInnerSpacing before it, it belongs to the previous item's group + std::function submenu; // set: the ">>" entry is a submenu with these items instead of an action }; -void beginToolbar(); // item spacing and button padding of a tool bar, until endToolbar() -void endToolbar(); +ToolbarItem toolbarAction(const char *id, const char *icon, const char *label, std::function trigger, + bool enabled = true, bool tight = false); +// A drop-down button that opens `items` in a popup; in the overflow menu they become a submenu. +// width 0: sized to the text. +ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *label, std::function items, + bool bold = false, bool tight = false, float width = 0.0f); float toolbarButtonWidth(const std::string &label); // the width of every item plus the spacing between neighbors and the two groups float toolbarWidth(const std::vector &items, size_t spacer_index); // items before spacer_index sit at the left, the rest are right aligned; the overflow goes into the ">>" menu -void drawToolbar(const std::vector &items, size_t spacer_index); +// width < 0 uses the available content width. +void drawToolbar(const std::vector &items, size_t spacer_index, float width = -1.0f); -// an auto-raise button that opens `popup_id` below itself, with a dropdown arrow after the text. width 0: +// Opens `popup_id` below the button on press. width 0: // sized to the text, otherwise the text and the arrow are centered in the button float menuButtonWidth(const std::string &text, bool bold = false); bool menuButton(const char *id, const std::string &text, const char *popup_id, bool bold = false, float width = 0.0f); -// a 13x13 handle filled with a subtle vertical gradient and a mid grey outline void drawSliderHandle(ImDrawList *p, const ImRect &r); -// full width groove, filled left of the handle, 13x13 handle (style.cc) bool fusionSliderInt(const char *label, int *v, int min, int max, float width); - -ImFont *boldFont(); -ImFont *monoFont(); -void pushMonoFont(float size = 0.0f); // 0: the size the font was loaded at -void popMonoFont(); -void pushBoldFont(); -void popBoldFont(); -void pushLargeFont(); -void popLargeFont(); diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index 7f9ac8b7b2..4469273607 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -296,8 +296,10 @@ void BinaryView::draw() { } const int rows = row_count_; - const float width = ImGui::GetContentRegionAvail().x; - column_width_ = std::max(1.0f, (width - VERTICAL_HEADER_WIDTH) / COLUMN_COUNT); + // Keep hex bytes readable in narrow panels by scrolling instead of shrinking further. + const float min_column_width = std::ceil(ImGui::CalcTextSize("FF").x) + 10.0f; + const float width = std::max(ImGui::GetContentRegionAvail().x, VERTICAL_HEADER_WIDTH + min_column_width * COLUMN_COUNT); + column_width_ = std::max(min_column_width, (width - VERTICAL_HEADER_WIDTH) / COLUMN_COUNT); grid_pos_ = ImGui::GetCursorScreenPos(); ImGui::InvisibleButton("##binary_view", ImVec2(std::max(width, 1.0f), std::max(static_cast(rows * CELL_HEIGHT), 1.0f))); ImDrawList *painter = ImGui::GetWindowDrawList(); @@ -448,9 +450,8 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); } } else if (isSelected(index)) { - auto color = resize_sig_ ? toImU32(resize_sig_->color) : paletteHighlight(); - painter->AddRectFilled(rect.Min, rect.Max, color); - pen = paletteBrightText(); + painter->AddRectFilled(rect.Min, rect.Max, resize_sig_ ? toImU32(resize_sig_->color) : paletteHighlight()); + if (resize_sig_) pen = IM_COL32_WHITE; } else if (!hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), resize_sig_) == item->sigs.end()) { // not resizing if (item->sigs.size() > 0) { for (auto &s : item->sigs) { @@ -465,7 +466,7 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); } bool bright = std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); - pen = bright ? paletteBrightText() : paletteText(is_message_active_); + pen = bright ? IM_COL32_WHITE : paletteText(is_message_active_); } if (item->sigs.size() > 1) { diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.cc b/openpilot/tools/cabana/ui/widgets/cameraview.cc index 6ffd099fe9..1f730344d5 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.cc +++ b/openpilot/tools/cabana/ui/widgets/cameraview.cc @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "imgui_impl_opengl3_loader.h" @@ -62,7 +63,7 @@ CameraWidget::~CameraWidget() { void CameraWidget::startVipcThread() { if (!vipc_thread_.joinable()) { - clearFrames(); + // Preserve the last frame when restoring a collapsed video; paused replay sends no replacement. vipc_exit_ = false; vipc_thread_ = std::thread(&CameraWidget::vipcThread, this); } @@ -99,7 +100,7 @@ float CameraWidget::frameAspectRatio() const { void CameraWidget::paint() { ImDrawList *p = ImGui::GetWindowDrawList(); - p->AddRectFilled(rect_.Min, rect_.Max, bg_); + p->AddRectFilled(rect_.Min, rect_.Max, bg_, ImGui::GetStyle().ChildRounding); std::lock_guard lk(frame_lock_); if (rgb_frame_.isNull()) return; @@ -113,24 +114,26 @@ void CameraWidget::paint() { // mirror cabin camera horizontally std::swap(placement.uv0.x, placement.uv1.x); } - p->AddImage(frame_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1); + p->AddImageRounded(frame_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1, IM_COL32_WHITE, ImGui::GetStyle().ChildRounding); } void CameraWidget::vipcThread() { VisionStreamType cur_stream = requested_stream_type_; std::unique_ptr vipc_client; VisionIpcBufExtra frame_meta = {}; + bool was_connected = false; while (!vipc_exit_) { if (!vipc_client || cur_stream != requested_stream_type_) { - clearFrames(); + if (cur_stream != requested_stream_type_) clearFrames(); cur_stream = requested_stream_type_; vipc_client.reset(new VisionIpcClient(stream_name_, cur_stream, false)); } active_stream_type_ = cur_stream; if (!vipc_client->connected) { - clearFrames(); + // the server changed (a new route): the last frame is stale. A fresh thread keeps it, see startVipcThread(). + if (std::exchange(was_connected, false)) clearFrames(); auto streams = VisionIpcClient::getAvailableStreams(stream_name_, false); if (streams.empty()) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); @@ -142,6 +145,7 @@ void CameraWidget::vipcThread() { std::this_thread::sleep_for(std::chrono::milliseconds(100)); continue; } + was_connected = true; } if (VisionBuf *buf = vipc_client->recv(&frame_meta, 100)) { diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index ebb74ffa4a..00ed44b09d 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -74,41 +74,40 @@ DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { void DetailWidget::drawToolBar() { const ImGuiStyle &style = ImGui::GetStyle(); - auto radio_width = [&](const char *label) { return ImGui::GetFrameHeight() + style.ItemInnerSpacing.x + ImGui::CalcTextSize(label).x; }; - auto button_width = [&](const char *label) { return ImGui::CalcTextSize(label).x + style.FramePadding.x * 2; }; - const float right_width = ImGui::CalcTextSize("Heatmap:").x + style.ItemSpacing.x + radio_width("Live") + style.ItemSpacing.x + - radio_width(heatmap_all_text_.c_str()) + style.ItemSpacing.x * 3 + 1.0f + - button_width(icon::PENCIL) + style.ItemSpacing.x + button_width(icon::X_LG); - const float avail = ImGui::GetContentRegionAvail().x; + std::vector items; + float name_width = 0.0f; + items.push_back({0.0f, [this, &name_width]() { + ImGui::AlignTextToFramePadding(); + pushBoldFont(); + name_label_.draw(name_width); + popBoldFont(); + }}); + items.back().in_menu = false; + const size_t spacer_index = items.size(); + const std::string heatmap_text = "Heatmap: " + (heatmap_live_ ? std::string("Live") : heatmap_all_text_); + auto heatmap_items = [this]() { + if (ImGui::MenuItem("Live", nullptr, heatmap_live_) && !heatmap_live_) { + heatmap_live_ = true; + binary_view_->setHeatmapLiveMode(true); + } + if (ImGui::MenuItem(heatmap_all_text_.c_str(), nullptr, !heatmap_live_) && heatmap_live_) { + heatmap_live_ = false; + binary_view_->setHeatmapLiveMode(false); + } + }; + items.push_back(toolbarMenu("heatmap", heatmap_text, "Heatmap", heatmap_items)); + items.push_back({1.0f, []() { ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); }}); + items.back().in_menu = false; + // Capture the panel width before the action can run inside the overflow popup. + const float panel_width = ImGui::GetWindowWidth(); + items.push_back(toolbarAction("edit_msg", icon::PENCIL, "Edit Message", [this, panel_width]() { editMsg(panel_width); })); + items.push_back(toolbarAction("remove_msg", icon::TRASH, "Remove Message", + [this]() { UndoStack::instance()->push(new RemoveMsgCommand(msg_id_)); }, action_remove_msg_enabled_, true)); - ImGui::AlignTextToFramePadding(); - pushBoldFont(); - name_label_.draw(std::max(1.0f, avail - right_width - style.ItemSpacing.x)); - popBoldFont(); - - alignRight(right_width); - ImGui::TextUnformatted("Heatmap:"); - ImGui::SameLine(); - if (ImGui::RadioButton("Live##heatmap_live_", heatmap_live_) && !heatmap_live_) { - heatmap_live_ = true; - binary_view_->setHeatmapLiveMode(true); - } - ImGui::SameLine(); - if (ImGui::RadioButton((heatmap_all_text_ + "##heatmap_all").c_str(), !heatmap_live_) && heatmap_live_) { - heatmap_live_ = false; - binary_view_->setHeatmapLiveMode(false); - } - - ImGui::SameLine(); - ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); - ImGui::SameLine(); - if (ImGui::Button(icon::PENCIL)) editMsg(); - ImGui::SetItemTooltip("Edit Message"); - ImGui::SameLine(); - ImGui::BeginDisabled(!action_remove_msg_enabled_); - if (ImGui::Button(icon::X_LG)) UndoStack::instance()->push(new RemoveMsgCommand(msg_id_)); - ImGui::EndDisabled(); - disabledItemTooltip("Remove Message"); + const float right_width = toolbarWidth(items, spacer_index) - style.ItemSpacing.x; + name_width = std::max(ImGui::CalcTextSize("MMMMMM").x, ImGui::GetContentRegionAvail().x - right_width - style.ItemSpacing.x); + items[0].width = name_width; + drawToolbar(items, spacer_index); } void DetailWidget::showTabBarContextMenu(int index) { @@ -203,34 +202,32 @@ void DetailWidget::updateState(const std::set *msgs) { history_log_->updateState(); } -void DetailWidget::editMsg() { +void DetailWidget::editMsg(float parent_width) { auto msg = dbc()->msg(msg_id_); int size = msg ? msg->size : can->lastMessage(msg_id_).dat.size(); - edit_dlg_ = std::make_unique(msg_id_, msgName(msg_id_), size, ImGui::GetWindowWidth()); + edit_dlg_ = std::make_unique(msg_id_, msgName(msg_id_), size, parent_width); } void DetailWidget::drawTabWidget() { - // the pages first, the tab bar below them - const float tab_height = ImGui::GetFrameHeight(); - const float content_height = ImGui::GetContentRegionAvail().y - tab_height - ImGui::GetStyle().ItemSpacing.y; - ImGui::BeginChild("tab_widget", ImVec2(0, std::max(content_height, 1.0f)), ImGuiChildFlags_None, + const ImGuiStyle &style = ImGui::GetStyle(); + const float pad = style.ItemInnerSpacing.x, pill_height = ImGui::GetFrameHeight() + pad * 2; + ImGui::BeginChild("tab_widget", ImVec2(0, 0), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + const ImRect page_rect = ImGui::GetCurrentWindow()->Rect(); + const float gap = style.WindowPadding.y; + ImGui::BeginChild("page", ImVec2(0, std::max(page_rect.GetHeight() - pill_height - gap, 1.0f)), + ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); if (tab_widget_index_ == 0) { // binary_view_ keeps its size hint, signal_view_ takes the rest const float min_height = binary_view_->minimumSizeHint().y; const float avail = ImGui::GetContentRegionAvail().y; const float max_height = std::max(avail - 6.0f - ImGui::GetStyle().ItemSpacing.y * 2 - 1.0f, 1.0f); const float height = std::clamp(min_height, 1.0f, max_height); - ImGui::BeginChild("binary_view", ImVec2(0, height)); + ImGui::BeginChild("binary_view", ImVec2(0, height), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); binary_view_->draw(); ImGui::EndChild(); ImGui::Dummy(ImVec2(0.0f, 6.0f)); - const float spacing = ImGui::GetStyle().ItemSpacing.y; - const ImRect child_rect = ImGui::GetCurrentWindow()->Rect(); - ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(child_rect.Min.x, ImGui::GetItemRectMin().y - spacing), - ImVec2(child_rect.Max.x, ImGui::GetItemRectMax().y + spacing), - ImGui::GetColorU32(ImGuiCol_WindowBg)); ImGui::BeginChild("signal_view", ImVec2(0, 0)); signal_view_rect_ = ImGui::GetCurrentWindow()->Rect(); signal_view_->draw(); @@ -240,33 +237,44 @@ void DetailWidget::drawTabWidget() { } ImGui::EndChild(); - const std::string labels[] = {std::string(icon::FILE_EARMARK_RULED) + " Messages", std::string(icon::STOPWATCH) + " Logs"}; - // the tabs are centered in the bar: the bar itself starts at the first tab, so its separator only spans the - // tabs and the full width one is drawn underneath it - const ImGuiStyle &style = ImGui::GetStyle(); - float tabs_width = 0.0f; + std::string labels[] = {std::string(icon::FILE_EARMARK_RULED) + " Messages", std::string(icon::STOPWATCH) + " Logs"}; + auto pill_width = [&]() { + float w = pad; + for (const auto &label : labels) w += ImGui::CalcTextSize(label.c_str()).x + style.FramePadding.x * 2 + pad; + return w; + }; + float width = pill_width(); + if (width > page_rect.GetWidth()) { + labels[0] = icon::FILE_EARMARK_RULED; + labels[1] = icon::STOPWATCH; + width = pill_width(); + } + const ImVec2 size(width, pill_height); + const ImVec2 min(std::round(page_rect.GetCenter().x - width * 0.5f), page_rect.Max.y - size.y); + ImGui::SetNextWindowPos(min); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(pad, pad)); + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::GetColorU32(ImGuiCol_PopupBg)); + ImGui::BeginChild("page_switch", size, ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(pad, 0.0f)); for (int i = 0; i < 2; ++i) { - tabs_width += ImGui::TabItemCalcSize(labels[i].c_str(), false).x + (i ? style.ItemInnerSpacing.x : 0.0f); - } - ImGuiWindow *window = ImGui::GetCurrentWindow(); - const float separator_y = ImGui::GetCursorScreenPos().y + ImGui::GetFrameHeight() - 1.0f; - window->DrawList->AddLine(ImVec2(window->WorkRect.Min.x, separator_y), ImVec2(window->WorkRect.Max.x, separator_y), - ImGui::GetColorU32(ImGuiCol_TabSelected), style.TabBarBorderSize); - ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (ImGui::GetContentRegionAvail().x - tabs_width) * 0.5f)); - - if (ImGui::BeginTabBar("tab_widget_tabs")) { - for (int i = 0; i < 2; ++i) { - if (ImGui::BeginTabItem(labels[i].c_str())) { - if (tab_widget_index_ != i) { - tab_widget_index_ = i; - if (i == 1) history_log_->onShown(); - updateState(); - } - ImGui::EndTabItem(); - } + const bool selected = tab_widget_index_ == i; + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetColorU32(selected ? ImGuiCol_Header : ImGuiCol_Button, selected ? 1.0f : 0.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::GetColorU32(selected ? ImGuiCol_HeaderActive : ImGuiCol_ButtonHovered)); + if (i) ImGui::SameLine(); + if (ImGui::Button(labels[i].c_str()) && !selected) { + tab_widget_index_ = i; + if (i == 1) history_log_->onShown(); + updateState(); } - ImGui::EndTabBar(); + ImGui::PopStyleColor(2); } + ImGui::PopStyleVar(2); + ImGui::EndChild(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); + ImGui::EndChild(); } void DetailWidget::draw() { @@ -321,8 +329,11 @@ bool EditMessageDialog::draw() { ImGui::OpenPopup(window_title_.c_str()); opened_ = true; } - setNextDialogWindow(ImVec2(0.0f, 0.0f)); - ImGui::SetNextWindowSize(ImVec2(width_, 0.0f), ImGuiCond_Always); // fixed width, the height fits the form + // The form needs room for message names and comments even when its panel is narrow. + const float max_width = std::max(1.0f, ImGui::GetMainViewport()->WorkSize.x - ImGui::GetStyle().WindowPadding.x * 2); + const float min_width = std::min(600.0f, max_width); + ImGui::SetNextWindowSizeConstraints(ImVec2(min_width, 0.0f), ImVec2(max_width, FLT_MAX)); + setNextDialogWindow(ImVec2(std::clamp(width_, min_width, max_width), 0.0f)); bool open = true; if (ImGui::BeginPopupModal(window_title_.c_str(), &open)) { const float label_width = ImGui::CalcTextSize("Comment").x + ImGui::GetStyle().ItemSpacing.x * 2; @@ -401,9 +412,6 @@ void CenterWidget::draw() { } void CenterWidget::drawWelcomeWidget() { - const ImVec2 win_pos = ImGui::GetWindowPos(), win_size = ImGui::GetWindowSize(); - ImGui::GetWindowDrawList()->AddRectFilled(win_pos, ImVec2(win_pos.x + win_size.x, win_pos.y + win_size.y), ImGui::GetColorU32(ImGuiCol_ChildBg)); - const ImVec2 avail = ImGui::GetContentRegionAvail(); const ImVec2 origin = ImGui::GetCursorPos(); auto centered = [&](const char *text, float y) { diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.h b/openpilot/tools/cabana/ui/widgets/detailwidget.h index 08d3d7db41..7f8efde99c 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.h +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.h @@ -73,7 +73,7 @@ private: void drawTabWidget(); int findOrAddTab(const MessageId& message_id); void showTabBarContextMenu(int index); - void editMsg(); + void editMsg(float parent_width); void updateState(const std::set *msgs = nullptr); MessageId msg_id_; diff --git a/openpilot/tools/cabana/ui/widgets/historylog.cc b/openpilot/tools/cabana/ui/widgets/historylog.cc index ec2ef844ff..ba51c4d6ee 100644 --- a/openpilot/tools/cabana/ui/widgets/historylog.cc +++ b/openpilot/tools/cabana/ui/widgets/historylog.cc @@ -133,9 +133,9 @@ void LogsWidget::draw() { const ImGuiStyle &style = ImGui::GetStyle(); // toolbar: the export button is right aligned and never clipped, the value input shrinks first - const float export_w = ImGui::CalcTextSize(icon::FILETYPE_CSV).x + style.FramePadding.x * 2; + const float export_w = iconButtonWidth(); if (!sigs_.empty()) { - const float clear_w = value_edit_.empty() ? 0.0f : ImGui::CalcTextSize(icon::X).x + style.FramePadding.x * 2; + const float clear_w = value_edit_.empty() ? 0.0f : iconButtonWidth(); const float fixed = DISPLAY_TYPE_WIDTH + SIGNALS_WIDTH + COMPARE_WIDTH + clear_w + style.ItemSpacing.x * 4 + export_w; const float value_w = std::clamp(ImGui::GetContentRegionAvail().x - fixed, 30.0f, 120.0f); @@ -164,9 +164,9 @@ void LogsWidget::draw() { filterChanged(); } } - alignRight(export_w); + alignRight(iconButtonWidth()); ImGui::BeginDisabled(!export_btn_enabled_); - if (ImGui::Button(icon::FILETYPE_CSV)) exportToCSV(); + if (iconButton("export_csv", icon::FILETYPE_CSV)) exportToCSV(); ImGui::EndDisabled(); disabledItemTooltip("Export to CSV file..."); @@ -196,10 +196,10 @@ void LogsWidget::drawHeaderCell(ImDrawList *dl, const ImRect &rect, int column) if (column > 0 && !hexMode()) { CabanaColor bg = sigs_[column - 1]->color; bg.a = 128; - dl->AddRectFilled(rect.Min, rect.Max, toImU32(bg)); + dl->AddRectFilled(rect.Min, rect.Max, toImU32(bg), ImGui::GetStyle().FrameRounding); } const std::string text = headerText(column); - const ImU32 color = isDarkTheme() ? toImU32(DarkTheme::bright_text) : ImGui::GetColorU32(ImGuiCol_Text); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); // right aligned and word wrapped, one line at a time const ImRect r(rect.Min.x + 5, rect.Min.y + 3, rect.Max.x - 5, rect.Max.y - 3); ImFont *font = ImGui::GetFont(); @@ -243,7 +243,7 @@ void LogsWidget::drawTable() { // fixed section sizes and a horizontal scrollbar, no alternating row colors; the grid is drawn between // rows and columns - ImGuiTableFlags flags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_ScrollX | ImGuiTableFlags_BordersInner | + ImGuiTableFlags flags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_ScrollX | ImGuiTableFlags_Borders | ImGuiTableFlags_SizingFixedFit; // an empty viewport draws no grid if (messages_.empty()) flags &= ~ImGuiTableFlags_BordersInnerV; @@ -279,13 +279,14 @@ void LogsWidget::drawTable() { if (!ImGui::TableSetColumnIndex(col)) continue; // cells are selected, not rows; there is no hover highlight, only the selection background const bool cell_selected = selected_row_ == row && selected_col_ == col; + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const ImRect rect(pos, ImVec2(pos.x + ImGui::GetContentRegionAvail().x, pos.y + row_height - style.CellPadding.y * 2)); ImGui::PushID(col); - if (viewSelectable("##cell", cell_selected, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, row_height - style.CellPadding.y * 2))) { + if (viewSelectable("##cell", cell_selected, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, rect.GetHeight()))) { selected_row_ = row; selected_col_ = col; } ImGui::PopID(); - const ImRect rect = ImGui::TableGetCellBgRect(table, col); if (col == 0) { drawTextCell(painter, rect, formatTime(m.mono_time), cell_selected, false); } else if (hexMode()) { diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.cc b/openpilot/tools/cabana/ui/widgets/messagebytes.cc index 9052949eef..97261039ee 100644 --- a/openpilot/tools/cabana/ui/widgets/messagebytes.cc +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.cc @@ -24,7 +24,7 @@ ImVec2 bytesCellSize(int n, bool multiple_lines) { } ImU32 cellTextColor(bool selected, bool inactive) { - if (selected) return inactive ? withAlpha(highlightedTextColor(), 100) : highlightedTextColor(); + if (selected && inactive) return withAlpha(ImGui::GetColorU32(ImGuiCol_Text), 100); return ImGui::GetColorU32(inactive ? ImGuiCol_TextDisabled : ImGuiCol_Text); } diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc index 102960b9a9..6de1735d79 100644 --- a/openpilot/tools/cabana/ui/widgets/messageswidget.cc +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -240,26 +240,30 @@ std::string MessagesWidget::whatsThis() const { } void MessagesWidget::drawToolBar() { - ImGui::Dummy(ImVec2(0, std::max(0.0f, 9 - ImGui::GetStyle().ItemSpacing.y))); - if (ImGui::Button("Suppress Highlighted")) suppressHighlighted(true); - ImGui::SameLine(); - ImGui::BeginDisabled(!suppress_clear_enabled_); - const std::string clear_label = suppress_clear_text_ + "##suppress_clear"; - if (ImGui::Button(clear_label.c_str())) suppressHighlighted(false); - ImGui::EndDisabled(); - disabledItemTooltip("Clear suppressed"); - const ImGuiStyle &style = ImGui::GetStyle(); - const float checkbox_width = ImGui::CalcTextSize("Suppress Signals").x + ImGui::GetFrameHeight() + style.ItemInnerSpacing.x; - const float view_button_width = ImGui::CalcTextSize(icon::THREE_DOTS).x + style.FramePadding.x * 2; - alignRight(checkbox_width + style.ItemSpacing.x + view_button_width); + // Reserve space for View so it remains accessible when other controls overflow. + const std::string clear_label = suppress_clear_text_ + "##suppress_clear"; + std::vector items; + items.push_back({toolbarButtonWidth("Suppress Highlighted"), [this]() { + if (ImGui::Button("Suppress Highlighted")) suppressHighlighted(true); + }, "Suppress Highlighted", [this]() { suppressHighlighted(true); }}); + items.push_back({toolbarButtonWidth(suppress_clear_text_), [this, &clear_label]() { + ImGui::BeginDisabled(!suppress_clear_enabled_); + if (ImGui::Button(clear_label.c_str())) suppressHighlighted(false); + ImGui::EndDisabled(); + disabledItemTooltip("Clear suppressed"); + }, suppress_clear_text_, [this]() { suppressHighlighted(false); }, suppress_clear_enabled_}); + const size_t spacer_index = items.size(); + items.push_back({ImGui::CalcTextSize("Suppress Signals").x + CHECKBOX_SIZE + style.ItemInnerSpacing.x, []() { + bool suppress_defined_signals = settings.suppress_defined_signals; + if (checkBox("Suppress Signals", &suppress_defined_signals)) can->suppressDefinedSignals(suppress_defined_signals); + ImGui::SetItemTooltip("Suppress defined signals"); + }}); - bool suppress_defined_signals = settings.suppress_defined_signals; - if (checkBox("Suppress Signals", &suppress_defined_signals)) can->suppressDefinedSignals(suppress_defined_signals); - ImGui::SetItemTooltip("Suppress defined signals"); + const float reserved = iconButtonWidth() + style.ItemSpacing.x; + drawToolbar(items, spacer_index, std::max(0.0f, ImGui::GetContentRegionAvail().x - reserved)); ImGui::SameLine(); - - if (toolButton("view_btn", icon::THREE_DOTS, "View...")) ImGui::OpenPopup("menu"); + if (iconButton("view_btn", icon::THREE_DOTS, "View...")) ImGui::OpenPopup("menu"); } void MessagesWidget::updateTitle() { @@ -430,7 +434,7 @@ void MessagesWidget::drawHeader() { } // the filter editors under the header - const float clear_width = ImGui::CalcTextSize(icon::X).x + ImGui::GetStyle().FramePadding.x * 2; + const float clear_width = iconButtonWidth(); ImGui::TableNextRow(); for (int i = 0; i < MessageList::COLUMN_COUNT; i++) { if (!ImGui::TableSetColumnIndex(i)) continue; diff --git a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc index feda7ce34d..75d7c42788 100644 --- a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc +++ b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc @@ -9,7 +9,7 @@ namespace { float scrollButtonsWidth() { const ImGuiStyle &style = ImGui::GetStyle(); - return ImGui::GetFrameHeight() * 2.0f + style.ItemInnerSpacing.x + style.ItemSpacing.x * 2.0f; + return style.ItemSpacing.x + ImGui::GetFrameHeight() * 2.0f + style.ItemInnerSpacing.x; } void drawScrollButtons(ImGuiTabBar *tab_bar) { diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index 1eff979b02..c0ed091568 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -24,7 +24,6 @@ constexpr float SIGNAL_ROW_EXTRA = 5.0f; // the tool button in the row makes it constexpr float SIGNAL_ROW_SCALE = 1.25f; constexpr float FILTER_WIDTH = 160.0f; constexpr float SPARKLINE_SLIDER_WIDTH = 120.0f; -constexpr float COLLAPSE_ICON_SIZE = 12.0f; // WARNING: increasing the maximum range can result in severe performance degradation. // 30s is a reasonable value at present. constexpr int SPARKLINE_RANGE_MAX = 30; @@ -261,13 +260,13 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const ImRect rect(option_rect.Min.x + h_margin, option_rect.Min.y + v_margin, option_rect.Max.x - h_margin, option_rect.Max.y - v_margin); // selection background is painted by the row's Selectable - const ImU32 text_color = selected ? highlightedTextColor() : ImGui::GetColorU32(ImGuiCol_Text); + const ImU32 text_color = ImGui::GetColorU32(ImGuiCol_Text); if (column == 0) { if (item->type == SignalModel::Item::Sig) { // color label ImRect icon_rect(rect.Min.x, rect.Min.y, rect.Min.x + COLOR_LABEL_WIDTH, rect.Max.y); - painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(signalFillColor(item->sig->color).darker(item->highlight ? 125 : 0)), 3.0f); + painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(signalFillColor(item->sig->color).darker(item->highlight ? 125 : 0)), ImGui::GetStyle().FrameRounding); drawText(painter, icon_rect, std::to_string(item->row() + 1).c_str(), item->highlight ? IM_COL32_WHITE : IM_COL32_BLACK, nullptr, LABEL_FONT); @@ -276,7 +275,7 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const if (item->sig->type != cabana::Signal::Type::Normal) { const std::string indicator = multiplexIndicator(item->sig); ImRect indicator_rect(rect.Min.x, rect.Min.y, rect.Min.x + ImGui::CalcTextSize(indicator.c_str()).x, rect.Max.y); - painter->AddRectFilled(indicator_rect.Min, indicator_rect.Max, IM_COL32(160, 160, 164, 255), 3.0f); + painter->AddRectFilled(indicator_rect.Min, indicator_rect.Max, IM_COL32(160, 160, 164, 255), ImGui::GetStyle().FrameRounding); drawElidedText(painter, indicator_rect, indicator, IM_COL32_WHITE, false); rect.Min.x = indicator_rect.Max.x + h_margin * 2; } @@ -313,13 +312,12 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const } // signal value rect.Min.x += value_adjust; - rect.Max.x -= button_size_.x; - if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, true); - } else { - // no sparkline yet: the value still belongs against the buttons, where it sits once there is one - rect.Max.x -= button_size_.x; - if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, true); } + // Monospaced digits prevent the value width from changing during playback. + rect.Max.x -= button_size_.x; + pushMonoFont(ImGui::GetFontSize()); + if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, true); + popMonoFont(); } } @@ -385,7 +383,7 @@ void SignalView::drawEditor(SignalModel::Item *item) { const bool clicked = ImGui::Selectable("##editor", false, 0, ImVec2(0, rowHeight())); ImGui::PopStyleColor(); drawElidedText(ImGui::GetWindowDrawList(), ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()), model_.valueText(item), - highlightedTextColor(), false); + ImGui::GetColorU32(ImGuiCol_Text), false); if (clicked || take_focus) { desc_dlg_ = std::make_unique(item->sig->val_desc); desc_dlg_->title = item->sig->name; @@ -493,12 +491,15 @@ void SignalView::drawValueDescriptionDlg() { desc_sig_ = nullptr; } +static ImVec2 indexButtonsSize(float button) { + return ImVec2(button * 2 + ImGui::GetStyle().ItemInnerSpacing.x * 2, button); +} + SignalView::SignalView(ChartsWidget *charts) : charts_(charts) { settings.sparkline_range = std::clamp(settings.sparkline_range, 1, SPARKLINE_RANGE_MAX); - // seed the size of the [plot][remove] widget (two 22px tool buttons plus the spacing) so the first - // updateState() calls already leave room for the sparklines - button_size_ = ImVec2(22 * 2 + TOOLBAR_ITEM_SPACING, 22); + // Reserve button space for updateState() calls before the first draw (no frame yet: derive the frame height). + button_size_ = indexButtonsSize(UI_FONT_SIZE + ImGui::GetStyle().FramePadding.y * 2.0f); updateToolBar(); connections_.push_back(model_.rowsChanged.connect([this]() { rowsChanged(); })); @@ -617,12 +618,14 @@ float SignalView::widestValueWidth(const cabana::Signal *sig) { const double raw_max = sig->is_signed ? std::ldexp(1.0, sig->size - 1) - 1 : std::ldexp(1.0, sig->size) - 1; const double raw_min = sig->is_signed ? -std::ldexp(1.0, sig->size - 1) : 0.0; float width = 0; + pushMonoFont(ImGui::GetFontSize()); for (double raw : {raw_min, raw_max}) { width = std::max(width, textWidth(sig->formatValue(raw * sig->factor + sig->offset))); } for (const auto &[_, desc] : sig->val_desc) { width = std::max(width, textWidth(desc)); } + popMonoFont(); return width; } @@ -672,8 +675,7 @@ void SignalView::updateState(const std::set *msgs) { // the sparkline label, the range slider and the collapse button float SignalView::toolBarRightWidth(const std::string &range_label) { const ImGuiStyle &style = ImGui::GetStyle(); - return ImGui::CalcTextSize(range_label.c_str()).x + style.ItemSpacing.x + SPARKLINE_SLIDER_WIDTH + style.ItemSpacing.x + - ImGui::GetFont()->CalcTextSizeA(COLLAPSE_ICON_SIZE, FLT_MAX, 0.0f, icon::DASH_SQUARE).x + style.FramePadding.x * 2; + return ImGui::CalcTextSize(range_label.c_str()).x + style.ItemSpacing.x + SPARKLINE_SLIDER_WIDTH + style.ItemSpacing.x + iconButtonWidth(); } // the width at which the tool bar stops squishing: the signal count and the filter box on the left, the @@ -686,8 +688,10 @@ float SignalView::minimumWidth() { } void SignalView::draw() { + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::GetStyleColorVec4(ImGuiCol_WindowBg)); if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_Borders)) { ImGui::EndChild(); + ImGui::PopStyleColor(); return; } @@ -710,11 +714,7 @@ void SignalView::draw() { } ImGui::SetItemTooltip("Sparkline time range"); ImGui::SameLine(); - // auto-raise tool button with a 12x12 icon - ImGui::PushFont(ImGui::GetFont(), COLLAPSE_ICON_SIZE); - const bool collapse = toolButton("collapse_all", icon::DASH_SQUARE, "Collapse All"); - ImGui::PopFont(); - if (collapse) collapseAll(); + if (iconButton("collapse_all", icon::ARROWS_COLLAPSE, "Collapse All")) collapseAll(); drawTree(); drawValueDescriptionDlg(); @@ -724,6 +724,7 @@ void SignalView::draw() { current_row_ = model_.signalRow(current_sig_); // used when the row is removed ImGui::EndChild(); + ImGui::PopStyleColor(); } void SignalView::collapseAll() { @@ -872,27 +873,24 @@ bool SignalView::drawItem(SignalModel::Item *item, int depth, DrawContext &ctx) } void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { - // plot_btn + remove_btn, right aligned in the value column - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(3.0f, 2.0f)); - const ImVec2 btn_size(ImGui::CalcTextSize(icon::GRAPH_UP).x + 6.0f, ImGui::GetFrameHeight()); - const ImVec2 size(btn_size.x * 2 + TOOLBAR_ITEM_SPACING, btn_size.y); + const float spacing = ImGui::GetStyle().ItemInnerSpacing.x; + const ImVec2 size = indexButtonsSize(iconButtonWidth()); ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - size.x, rect.Min.y + (rect.GetHeight() - size.y) * 0.5f)); const auto sig = item->sig; const bool checked = item->chart_opened; if (checked) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); - if (ImGui::Button((std::string(icon::GRAPH_UP) + "##plot").c_str(), btn_size) && !editor_open_on_press_) { + if (iconButton("plot", icon::GRAPH_UP) && !editor_open_on_press_) { item->chart_opened = !checked; showChart(model_.msgId(), sig, item->chart_opened, ImGui::GetIO().KeyShift); } if (checked) ImGui::PopStyleColor(); ImGui::SetItemTooltip("%s", checked ? "Close Plot" : "Show Plot\nSHIFT click to add to previous opened plot"); - ImGui::SameLine(0.0f, TOOLBAR_ITEM_SPACING); - if (ImGui::Button((std::string(icon::X) + "##remove").c_str(), btn_size) && !editor_open_on_press_) { + ImGui::SameLine(0.0f, spacing); + if (iconButton("remove", icon::X_LG) && !editor_open_on_press_) { pending_action_ = [this, sig]() { UndoStack::instance()->push(new RemoveSigCommand(model_.msgId(), sig)); }; } ImGui::SetItemTooltip("Remove signal"); - ImGui::PopStyleVar(); button_size_ = size; } @@ -914,12 +912,12 @@ bool ValueDescriptionDlg::draw() { if (!ImGui::BeginPopupModal(popup_id.c_str(), &open, ImGuiWindowFlags_NoSavedSettings)) return ImGui::IsPopupOpen(popup_id.c_str()); bool closing = false; - if (ImGui::Button(icon::PLUS)) { + if (iconButton("add", icon::PLUS_LG, "Add")) { table_.emplace_back("", ""); } - ImGui::SameLine(); + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); ImGui::BeginDisabled(current_row_ == -1); - if (ImGui::Button(icon::DASH) && current_row_ < table_.size()) { + if (iconButton("remove", icon::DASH_LG, "Remove") && current_row_ < table_.size()) { table_.erase(table_.begin() + current_row_); current_row_ = -1; } diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 7cabac840a..91b41184c0 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -141,7 +141,6 @@ std::string VideoWidget::whatsThis() const { static float toolbarHeight() { return TOOLBAR_MARGIN_Y + ImGui::GetFrameHeight(); } void VideoWidget::drawPlaybackController() { - beginToolbar(); ImGui::SetCursorPosY(ImGui::GetCursorPosY() + TOOLBAR_MARGIN_Y); const float speed_width = menuButtonWidth("0.05x", true); @@ -152,37 +151,29 @@ void VideoWidget::drawPlaybackController() { : formatTime(can->currentSec(), true); const char *time_tooltip = settings.absolute_time ? "Elapsed time" : "Absolute time"; - auto seek_backward = []() { can->seekTo(can->currentSec() - 1); }; - auto toggle_play = []() { can->pause(!can->isPaused()); }; - auto seek_forward = []() { can->seekTo(can->currentSec() + 1); }; - std::vector items = { - {toolbarButtonWidth(icon::REWIND), [&]() { if (toolButton("rewind", icon::REWIND, "Seek backward")) seek_backward(); }, - "Seek backward", seek_backward}, - {toolbarButtonWidth(play_icon), [&]() { if (toolButton("play", play_icon, play_tooltip)) toggle_play(); }, - play_tooltip, toggle_play}, - {toolbarButtonWidth(icon::FAST_FORWARD), [&]() { if (toolButton("fast-forward", icon::FAST_FORWARD, "Seek forward")) seek_forward(); }, - "Seek forward", seek_forward}, + toolbarAction("rewind", icon::REWIND, "Seek backward", []() { can->seekTo(can->currentSec() - 1); }), + toolbarAction("play", play_icon, play_tooltip, []() { can->pause(!can->isPaused()); }, true, true), + toolbarAction("fast-forward", icon::FAST_FORWARD, "Seek forward", []() { can->seekTo(can->currentSec() + 1); }, true, true), }; if (can->liveStreaming()) { - items.push_back({toolbarButtonWidth(icon::SKIP_END), [&]() { - ImGui::BeginDisabled(!skip_to_end_enabled_); - if (toolButton("skip-end", icon::SKIP_END, "Skip to the end")) skipToEnd(); - ImGui::EndDisabled(); - }, "Skip to the end", [this]() { skipToEnd(); }, skip_to_end_enabled_}); + items.push_back(toolbarAction("skip-end", icon::SKIP_END, "Skip to the end", [this]() { skipToEnd(); }, skip_to_end_enabled_, true)); } if (slider_ || msgs_received_) { // a mono font: with proportional digits the time changed width as it ticked and the items after it moved pushMonoFont(ImGui::GetFontSize()); - const float time_width = toolbarButtonWidth(time_text); + const float time_width = ImGui::CalcTextSize(time_text.c_str()).x; popMonoFont(); items.push_back({time_width, [&]() { pushMonoFont(ImGui::GetFontSize()); - if (toolButton("time_display", time_text.c_str(), time_tooltip)) toggleTimeDisplay(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(time_text.c_str()); popMonoFont(); + if (ImGui::IsItemClicked()) toggleTimeDisplay(); + ImGui::SetItemTooltip("%s", time_tooltip); }, - time_text, [this]() { toggleTimeDisplay(); }}); + time_tooltip, [this]() { toggleTimeDisplay(); }}); } // the expanding spacer: the items after it are right aligned as long as everything fits const size_t spacer_index = items.size(); @@ -195,27 +186,22 @@ void VideoWidget::drawPlaybackController() { ImGui::GetWindowDrawList()->AddLine(ImVec2(x, min.y + 4.0f), ImVec2(x, min.y + ImGui::GetFrameHeight() - 4.0f), ImGui::GetColorU32(ImGuiCol_Separator)); }}; item.in_menu = false; + item.tight = true; return item; }; const char *aspect_ratio_icon = settings.crop_video ? icon::ASPECT_RATIO_FILL : icon::ASPECT_RATIO; - items.push_back({toolbarButtonWidth(aspect_ratio_icon), [&]() { - if (toolButton("crop_video", aspect_ratio_icon, "Crop to fill")) cropVideoClicked(); - }, "Crop to fill", [this]() { cropVideoClicked(); }}); + items.push_back(toolbarAction("crop_video", aspect_ratio_icon, "Crop to fill", [this]() { cropVideoClicked(); })); if (!can->liveStreaming()) { items.push_back(separator()); - items.push_back({toolbarButtonWidth(loop_icon), [&]() { if (toolButton("loop", loop_icon, "Loop playback")) loopPlaybackClicked(); }, - "Loop playback", [this]() { loopPlaybackClicked(); }}); + items.push_back(toolbarAction("loop", loop_icon, "Loop playback", [this]() { loopPlaybackClicked(); }, true, true)); } - items.push_back({speed_width, [&]() { drawSpeedDropdown(speed_width); }}); + items.push_back(toolbarMenu("speed_btn", speed_text_, "Speed", [this]() { drawSpeedMenuItems(); }, true, true, speed_width)); if (!can->liveStreaming()) { items.push_back(separator()); - items.push_back({toolbarButtonWidth(icon::INFO_CIRCLE), - [&]() { if (toolButton("route_info", icon::INFO_CIRCLE, "View route details")) showRouteInfo(); }, - "View route details", [this]() { showRouteInfo(); }}); + items.push_back(toolbarAction("route_info", icon::INFO_CIRCLE, "View route details", [this]() { showRouteInfo(); }, true, true)); } drawToolbar(items, spacer_index); - endToolbar(); } void VideoWidget::skipToEnd() { @@ -241,14 +227,6 @@ void VideoWidget::createSpeedDropdown() { speed_text_ = speedText(speeds[speed_index_]); } -void VideoWidget::drawSpeedDropdown(float width) { - menuButton("speed_btn", speed_text_, "speed_menu", true, width); - if (ImGui::BeginPopup("speed_menu")) { - drawSpeedMenuItems(); - ImGui::EndPopup(); - } -} - void VideoWidget::drawSpeedMenuItems() { // every row declares the same width, so the popup is exactly as wide as the widest one and all the // highlights reach both edges; the label is padded on the right as much as the check column on the left @@ -436,7 +414,7 @@ void Slider::paint(double thumbnail_time) { groove_rect.Min.y = std::floor(center_y - groove_height / 2); groove_rect.Max.y = groove_rect.Min.y + groove_height; - p->AddRectFilled(groove_rect.Min, groove_rect.Max, timeline_colors[(int)TimelineType::None]); + p->AddRectFilled(groove_rect.Min, groove_rect.Max, timeline_colors[(int)TimelineType::None], groove_height * 0.5f); double min = minimum() / factor; double max = maximum() / factor; @@ -471,7 +449,7 @@ void Slider::paint(double thumbnail_time) { if (thumbnail_time >= 0) { float left = rect_.Min.x + (float)((thumbnail_time - min) * width() / span) - 1; ImRect rc(ImVec2(left, rect_.Min.y + 1), ImVec2(left + 2, rect_.Max.y - 1)); - p->AddRectFilled(rc.Min, rc.Max, ImGui::GetColorU32(ImGuiCol_Header), 1.5f); // ImGuiCol_Header is the theme highlight + p->AddRectFilled(rc.Min, rc.Max, ImGui::GetColorU32(ImGuiCol_Header), 1.0f); } } @@ -558,10 +536,10 @@ const RgbImage *StreamCameraView::thumbnailAt(double sec) { } void StreamCameraView::drawScrubThumbnail(ImDrawList *p, double sec) { - p->AddRectFilled(rect().Min, rect().Max, IM_COL32(0, 0, 0, 255)); + p->AddRectFilled(rect().Min, rect().Max, IM_COL32(0, 0, 0, 255), ImGui::GetStyle().ChildRounding); if (const RgbImage *image = thumbnailAt(sec)) { const VideoPlacement placement = videoPlacement(rect(), (float)image->width / image->height, settings.crop_video); - p->AddImage(big_thumbnail_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1); + p->AddImageRounded(big_thumbnail_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1, IM_COL32_WHITE, ImGui::GetStyle().ChildRounding); drawTime(p, rect(), sec); } } @@ -578,8 +556,8 @@ void StreamCameraView::drawThumbnail(ImDrawList *p, double sec) { int y = height() - h - THUMBNAIL_MARGIN; ImRect thumb_rect(ImVec2(rect().Min.x + x, rect().Min.y + y), ImVec2(rect().Min.x + x + w, rect().Min.y + y + h)); - p->AddImage(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max); - p->AddRect(thumb_rect.Min, thumb_rect.Max, paletteBrightText(), 0.0f, 0, 2.0f); + p->AddImageRounded(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max, ImVec2(0, 0), ImVec2(1, 1), IM_COL32_WHITE, ImGui::GetStyle().FrameRounding); + p->AddRect(thumb_rect.Min, thumb_rect.Max, IM_COL32_WHITE, ImGui::GetStyle().FrameRounding, 0, 2.0f); // look up the alert at the hovered time, the thumbnail frame itself can be seconds away if (auto alert = getReplay()->findAlertAtTime(sec)) { drawAlert(p, thumb_rect, *alert, POINT_10_FONT_SIZE); @@ -595,11 +573,11 @@ void StreamCameraView::drawTime(ImDrawList *p, const ImRect &rect, double second const ImVec2 text_size = font->CalcTextSizeA(POINT_10_FONT_SIZE, FLT_MAX, 0.0f, text); // centered horizontally, above the bottom margin p->AddText(font, POINT_10_FONT_SIZE, ImVec2(rect.GetCenter().x - text_size.x / 2, rect.Max.y - THUMBNAIL_MARGIN - text_size.y), - paletteBrightText(), text); + IM_COL32_WHITE, text); } void StreamCameraView::drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size) { - const ImU32 pen = paletteBrightText(); + const ImU32 pen = IM_COL32_WHITE; ImU32 color = withAlpha(timeline_colors[int(alert.type)], 128); std::string text = alert.text1; if (!alert.text2.empty()) text += "\n" + alert.text2; @@ -608,7 +586,7 @@ void StreamCameraView::drawAlert(ImDrawList *p, const ImRect &rect, const Timeli ImFont *font = ImGui::GetFont(); const float wrap_width = std::max(1.0f, text_rect.GetWidth()); const ImVec2 r = font->CalcTextSizeA(font_size, FLT_MAX, wrap_width, text.c_str()); - p->AddRectFilled(ImVec2(text_rect.Min.x, text_rect.Min.y), ImVec2(text_rect.Max.x, text_rect.Min.y + r.y), color); + p->AddRectFilled(ImVec2(text_rect.Min.x, text_rect.Min.y), ImVec2(text_rect.Max.x, text_rect.Min.y + r.y), color, ImGui::GetStyle().FrameRounding, ImDrawFlags_RoundCornersTop); // each line is centered, wrapped continuations stay left aligned float y = text_rect.Min.y; for (const auto &line : utils::split(text, '\n')) { diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h index 90058850c3..a1f6076a64 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.h +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -102,7 +102,6 @@ private: void skipToEnd(); void toggleTimeDisplay(); void createSpeedDropdown(); - void drawSpeedDropdown(float width); void drawSpeedMenuItems(); void loopPlaybackClicked(); void cropVideoClicked(); diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index 203c504c44..3570320f34 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -60,20 +60,6 @@ ValidState validateIpAddress(const std::string &input); // C-locale floating-point ValidState validateDouble(const std::string &input); -struct DarkTheme { - static constexpr CabanaColor window{0x35, 0x35, 0x35}; - static constexpr CabanaColor window_text{0xff, 0xff, 0xff}; - static constexpr CabanaColor base{0x19, 0x19, 0x19}; - static constexpr CabanaColor tooltip_text{0xff, 0xff, 0xff}; - static constexpr CabanaColor text{0xff, 0xff, 0xff}; - static constexpr CabanaColor button{0x35, 0x35, 0x35}; - static constexpr CabanaColor highlight{0x2a, 0x82, 0xda}; - static constexpr CabanaColor bright_text{0xff, 0xff, 0xff}; - static constexpr CabanaColor disabled_text{0x7f, 0x7f, 0x7f}; - static constexpr CabanaColor light{0x50, 0x50, 0x50}; - static constexpr CabanaColor dark{0x23, 0x23, 0x23}; -}; - namespace utils { bool isMainThread(); From 6bbdf8ad18d86d8eff460dbb9568e1920a2115a6 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 6 Sep 2026 21:20:39 -0700 Subject: [PATCH 020/270] remove sentry (#38790) --- openpilot/system/manager/manager.py | 4 +- openpilot/system/manager/process.py | 4 +- openpilot/system/sentry.py | 73 ----------------------------- openpilot/system/tombstoned.py | 22 +++------ pyproject.toml | 1 - tools/release/README.md | 2 +- uv.lock | 15 ------ 7 files changed, 10 insertions(+), 111 deletions(-) delete mode 100644 openpilot/system/sentry.py diff --git a/openpilot/system/manager/manager.py b/openpilot/system/manager/manager.py index 86fca2723f..929d12754c 100755 --- a/openpilot/system/manager/manager.py +++ b/openpilot/system/manager/manager.py @@ -8,7 +8,6 @@ import traceback from openpilot.cereal import log import openpilot.cereal.messaging as messaging -import openpilot.system.sentry as sentry from openpilot.common.utils import atomic_write from openpilot.common.params import Params, ParamKeyFlag from openpilot.common.text_window import TextWindow @@ -78,7 +77,6 @@ def manager_init() -> None: os.environ['CLEAN'] = '1' # init logging - sentry.init(sentry.SentryProject.SELFDRIVE) cloudlog.bind_global(dongle_id=dongle_id, version=build_metadata.openpilot.version, origin=build_metadata.openpilot.git_normalized_origin, @@ -187,7 +185,7 @@ def main() -> None: manager_thread() except Exception: traceback.print_exc() - sentry.capture_exception() + cloudlog.exception("crash") finally: manager_cleanup() diff --git a/openpilot/system/manager/process.py b/openpilot/system/manager/process.py index 43ea3f7306..363f7f38e7 100644 --- a/openpilot/system/manager/process.py +++ b/openpilot/system/manager/process.py @@ -12,7 +12,6 @@ from setproctitle import setproctitle from openpilot.cereal import log from opendbc.car.structs import car import openpilot.cereal.messaging as messaging -import openpilot.system.sentry as sentry from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog @@ -31,7 +30,6 @@ def launcher(proc: str, name: str) -> None: # add daemon name tag to logs cloudlog.bind(daemon=name) - sentry.set_tag("daemon", name) # exec the process mod.main() @@ -40,7 +38,7 @@ def launcher(proc: str, name: str) -> None: except Exception: # can't install the crash handler because sys.excepthook doesn't play nice # with threads, so catch it here. - sentry.capture_exception() + cloudlog.exception("crash") raise diff --git a/openpilot/system/sentry.py b/openpilot/system/sentry.py deleted file mode 100644 index 8a4e1bb9f2..0000000000 --- a/openpilot/system/sentry.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Install exception handler for process crash.""" -import sentry_sdk -from enum import Enum -from sentry_sdk.integrations.threading import ThreadingIntegration - -from openpilot.common.params import Params -from openpilot.system.athena.registration import is_registered_device -from openpilot.common.hardware import HARDWARE, PC -from openpilot.common.swaglog import cloudlog -from openpilot.common.version import get_build_metadata, get_version - - -class SentryProject(Enum): - # python project - SELFDRIVE = "https://6f3c7076c1e14b2aa10f5dde6dda0cc4@o33823.ingest.sentry.io/77924" - # native project - SELFDRIVE_NATIVE = "https://3e4b586ed21a4479ad5d85083b639bc6@o33823.ingest.sentry.io/157615" - - -def report_tombstone(fn: str, message: str, contents: str) -> None: - cloudlog.error({'tombstone': message}) - - with sentry_sdk.configure_scope() as scope: - scope.set_extra("tombstone_fn", fn) - scope.set_extra("tombstone", contents) - sentry_sdk.capture_message(message=message) - sentry_sdk.flush() - - -def capture_exception(*args, **kwargs) -> None: - cloudlog.error("crash", exc_info=kwargs.get('exc_info', 1)) - - try: - sentry_sdk.capture_exception(*args, **kwargs) - sentry_sdk.flush() # https://github.com/getsentry/sentry-python/issues/291 - except Exception: - cloudlog.exception("sentry exception") - - -def set_tag(key: str, value: str) -> None: - sentry_sdk.set_tag(key, value) - - -def init(project: SentryProject) -> bool: - build_metadata = get_build_metadata() - # forks like to mess with this, so double check - comma_remote = build_metadata.openpilot.comma_remote and "commaai" in build_metadata.openpilot.git_origin - if not comma_remote or not is_registered_device() or PC: - return False - - env = "release" if build_metadata.tested_channel else "master" - dongle_id = Params().get("DongleId") - - integrations = [] - if project == SentryProject.SELFDRIVE: - integrations.append(ThreadingIntegration(propagate_hub=True)) - - sentry_sdk.init(project.value, - default_integrations=False, - release=get_version(), - integrations=integrations, - traces_sample_rate=1.0, - max_value_length=8192, - environment=env) - - sentry_sdk.set_user({"id": dongle_id}) - sentry_sdk.set_tag("dirty", build_metadata.openpilot.is_dirty) - sentry_sdk.set_tag("origin", build_metadata.openpilot.git_origin) - sentry_sdk.set_tag("branch", build_metadata.channel) - sentry_sdk.set_tag("commit", build_metadata.openpilot.git_commit) - sentry_sdk.set_tag("device", HARDWARE.get_device_type()) - - return True diff --git a/openpilot/system/tombstoned.py b/openpilot/system/tombstoned.py index 7741a923cd..7661ec7de5 100755 --- a/openpilot/system/tombstoned.py +++ b/openpilot/system/tombstoned.py @@ -9,10 +9,11 @@ import time import glob from typing import NoReturn -import openpilot.system.sentry as sentry +from openpilot.common.hardware import PC from openpilot.common.hardware.hw import Paths from openpilot.common.swaglog import cloudlog from openpilot.common.version import get_build_metadata +from openpilot.system.athena.registration import is_registered_device MAX_SIZE = 1_000_000 * 100 # allow up to 100M MAX_TOMBSTONE_FN_LEN = 62 # 85 - 23 ("/crash/") @@ -65,22 +66,12 @@ def report_tombstone_apport(fn): return message = "" # One line description of the crash - contents = "" # Full file contents without coredump path = "" # File path relative to openpilot directory - proc_maps = False - with open(fn) as f: for line in f: if "CoreDump" in line: break - elif "ProcMaps" in line: - proc_maps = True - elif "ProcStatus" in line: - proc_maps = False - - if not proc_maps: - contents += line if "ExecutablePath" in line: path = line.strip().split(': ')[-1] @@ -112,13 +103,12 @@ def report_tombstone_apport(fn): if not found: crash_function = stacktrace_s[1] - # Remove arguments that can contain pointers to make sentry one-liner unique + # Remove arguments that can contain pointers from the crash summary crash_function = " ".join(x for x in crash_function.split(' ')[1:] if not x.startswith('0x')) crash_function = re.sub(r'\(.*?\)', '', crash_function) - contents = stacktrace + "\n\n" + contents message = message + " - " + crash_function - sentry.report_tombstone(fn, message, contents) + cloudlog.error({'tombstone': message}) # Copy crashlog to upload folder clean_path = path.replace('/', '_') @@ -141,7 +131,9 @@ def report_tombstone_apport(fn): def main() -> NoReturn: - should_report = sentry.init(sentry.SentryProject.SELFDRIVE_NATIVE) + build_metadata = get_build_metadata() + comma_remote = build_metadata.openpilot.comma_remote and "commaai" in build_metadata.openpilot.git_origin + should_report = comma_remote and is_registered_device() and not PC # Clear apport folder on start, otherwise duplicate crashes won't register clear_apport_folder() diff --git a/pyproject.toml b/pyproject.toml index 78fb53ed1d..7be8f97c0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,6 @@ dependencies = [ # these should be removed "pyzmq", - "sentry-sdk", "setproctitle", "jeepney", "zstandard", # this can go once we're on Python 3.14+ diff --git a/tools/release/README.md b/tools/release/README.md index 862836bf90..7e2e7e2faf 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -22,7 +22,7 @@ - [ ] fresh install with `openpilot-test.comma.ai` - [ ] drive on fresh install - [ ] no submodules or LFS - - [ ] check sentry, MTBF, etc. + - [ ] check MTBF, etc. - [ ] stress test passes in production - [ ] publish the blog post - [ ] `git reset --hard origin/release-mici-staging` diff --git a/uv.lock b/uv.lock index dc9cb69845..c27429f5f2 100644 --- a/uv.lock +++ b/uv.lock @@ -609,7 +609,6 @@ dependencies = [ { name = "pyzmq" }, { name = "requests" }, { name = "scons" }, - { name = "sentry-sdk" }, { name = "setproctitle" }, { name = "sounddevice" }, { name = "tqdm" }, @@ -676,7 +675,6 @@ requires-dist = [ { name = "requests" }, { name = "ruff", marker = "extra == 'testing'" }, { name = "scons" }, - { name = "sentry-sdk" }, { name = "setproctitle" }, { name = "sounddevice" }, { name = "teleoprtc", marker = "extra == 'submodules'", editable = "teleoprtc_repo" }, @@ -930,19 +928,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/43/d6285848e893c19682c06e92679dc1a07d37ff7ea148747b1df681ec496c/scons-4.11.1-py3-none-any.whl", hash = "sha256:454cef364348053422696e3d2ecb4fa593c96a624f955842eaaea64f95c8d11d", size = 4123659, upload-time = "2026-08-27T04:33:12.728Z" }, ] -[[package]] -name = "sentry-sdk" -version = "2.68.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/94/23b7dd072acb9628907bd3f4fbf61794a7b12a9db8f33c1276f70ae5ac92/sentry_sdk-2.68.0.tar.gz", hash = "sha256:648c58e9887311a03470a41539e24bdbbf64a30ca4f5336f7e3dcc87276400b3", size = 1008854, upload-time = "2026-08-13T09:06:21.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/9b/e2421d08956d0bc4691d995393d835e563886bff499d8fb10fdefae85a8d/sentry_sdk-2.68.0-py3-none-any.whl", hash = "sha256:538e56c2d03679d42f7c0cb5f1af73a7a510b00abc7e296c13ac49b107b713a4", size = 518670, upload-time = "2026-08-13T09:06:19.735Z" }, -] - [[package]] name = "setproctitle" version = "1.3.7" From 65c411433bebe2833825651b2ad2eedb418172ee Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:22:23 -0700 Subject: [PATCH 021/270] cabana: reduce downloader startup and dense chart rendering overhead (#38793) * cabana: reduce downloader startup and dense chart rendering overhead * cabana: remove downloader spawn tests --- openpilot/tools/cabana/tests/test_cabana.cc | 25 ++++++ openpilot/tools/cabana/ui/chart/chart.cc | 9 +- openpilot/tools/cabana/ui/chart/downsample.h | 39 +++++++++ openpilot/tools/lib/file_downloader.py | 10 +-- openpilot/tools/replay/py_downloader.cc | 87 +++++++++++++------- 5 files changed, 133 insertions(+), 37 deletions(-) create mode 100644 openpilot/tools/cabana/ui/chart/downsample.h diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 3523a47e08..e1c98fcc8e 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -10,6 +10,7 @@ #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/routes.h" #include "tools/cabana/ui/qtstate.h" +#include "tools/cabana/ui/chart/downsample.h" #include "tools/cabana/utils/strings.h" const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; @@ -353,7 +354,31 @@ void test_qt_state_blobs() { REQUIRE(!qtstate::parseQtHeaderState(fromHex("000000fe00000000000000010000000000000000010000000000000000")).has_value()); } +void test_pixel_envelope() { + struct Point { + double x, y; + Point(double x, double y) : x(x), y(y) {} + }; + std::vector points; + for (int i = 0; i < 1000; ++i) points.emplace_back(i * 0.001, i == 203 ? 99 : i == 201 ? -99 : 0); + const auto result = chart::pixelEnvelope(points.begin(), points.end(), 0, 1, 10); + REQUIRE(result.size() <= 40); + REQUIRE(result.front().x == points.front().x); + REQUIRE(result.back().x == points.back().x); + REQUIRE(std::is_sorted(result.begin(), result.end(), [](const auto &a, const auto &b) { return a.x < b.x; })); + REQUIRE(std::any_of(result.begin(), result.end(), [](const auto &p) { return p.x == .201 && p.y == -99; })); + REQUIRE(std::any_of(result.begin(), result.end(), [](const auto &p) { return p.x == .203 && p.y == 99; })); + const std::vector step{{-1, 0}, {0, 0}, {0, 10}, {0, -10}, {0, 0}, {2, 0}}; + const auto edge = chart::pixelEnvelope(step.begin(), step.end(), 0, 1, 2); + REQUIRE(edge.front().x == -1); + REQUIRE(edge.back().x == 2); + REQUIRE(std::any_of(edge.begin(), edge.end(), [](const auto &p) { return p.y == 10; })); + REQUIRE(std::any_of(edge.begin(), edge.end(), [](const auto &p) { return p.y == -10; })); + REQUIRE(chart::pixelEnvelope(points.begin(), points.begin(), 0, 1, 10).empty()); +} + void test_cabana_core() { + test_pixel_envelope(); test_format_seconds(); test_to_hex(); test_signal_tooltip(); diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index ea54fc819b..399304186e 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -11,6 +11,7 @@ #include "tools/cabana/core/settings.h" #include "tools/cabana/settings.h" #include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/chart/downsample.h" #include "tools/cabana/ui/icons.h" #include "tools/cabana/ui/util.h" #include "tools/cabana/utils/strings.h" @@ -665,7 +666,13 @@ void ChartView::drawSeries() { if (begin == end) continue; spec.LineWeight = 2; - ImPlot::PlotLine(label.c_str(), &begin->x, &begin->y, end - begin, spec); + const int pixels = std::max(1, (int)layout_.plot_area.GetWidth()); + if (end - begin > pixels * 4) { + const auto envelope = chart::pixelEnvelope(begin, end, x_min_, x_max_, pixels); + ImPlot::PlotLine(label.c_str(), &envelope.front().x, &envelope.front().y, envelope.size(), spec); + } else { + ImPlot::PlotLine(label.c_str(), &begin->x, &begin->y, end - begin, spec); + } // show points when zoomed in enough if ((num_points == 1 || pixels_per_point > 20) && first != last) { diff --git a/openpilot/tools/cabana/ui/chart/downsample.h b/openpilot/tools/cabana/ui/chart/downsample.h new file mode 100644 index 0000000000..e523125b62 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/downsample.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace chart { + +// Keep endpoints and both extrema in time order in each pixel column. Only rendering +// uses this envelope; calculations, cursor values and exports retain every sample. +template +auto pixelEnvelope(Iterator begin, Iterator end, double min, double max, int pixels) { + using Point = typename std::iterator_traits::value_type; + std::vector result; + if (begin == end || pixels <= 0 || max <= min) return result; + result.reserve(std::min(end - begin, (size_t)pixels * 4)); + auto column = [&](const auto &p) { + return (int)std::clamp((p.x - min) / (max - min) * pixels, 0.0, double(pixels - 1)); + }; + while (begin != end) { + auto last = begin, low = begin, high = begin, next = begin + 1; + const int bucket = column(*begin); + while (next != end && column(*next) == bucket) { + if (next->y < low->y) low = next; + if (next->y > high->y) high = next; + last = next++; + } + std::array selected{begin, low, high, last}; + std::sort(selected.begin(), selected.end()); + auto unique_end = std::unique(selected.begin(), selected.end()); + for (auto it = selected.begin(); it != unique_end; ++it) result.push_back(**it); + begin = next; + } + return result; +} + +} // namespace chart diff --git a/openpilot/tools/lib/file_downloader.py b/openpilot/tools/lib/file_downloader.py index efb06095be..fd517d7ada 100755 --- a/openpilot/tools/lib/file_downloader.py +++ b/openpilot/tools/lib/file_downloader.py @@ -19,16 +19,14 @@ import shutil import sys import tempfile -import zstandard as zstd - from openpilot.common.hardware.hw import Paths -from openpilot.tools.lib.api import CommaApi, UnauthorizedError, APIError -from openpilot.tools.lib.auth_config import get_token -from openpilot.tools.lib.url_file import URLFile def api_call(func): """Run an API call, outputting JSON result or error to stdout.""" + from openpilot.tools.lib.api import CommaApi, UnauthorizedError, APIError + from openpilot.tools.lib.auth_config import get_token + try: result = func(CommaApi(get_token())) json.dump(result, sys.stdout) @@ -62,6 +60,7 @@ def make_decompressor(compression): if compression == 'bz2': return bz2.BZ2Decompressor() if compression == 'zst': + import zstandard as zstd return zstd.ZstdDecompressor().decompressobj() raise ValueError(f"Unsupported compression type: {compression}") @@ -126,6 +125,7 @@ def cmd_download(args): try: # Stream the file in a single HTTP request instead of making # a separate Range request per chunk (which was very slow). + from openpilot.tools.lib.url_file import URLFile pool = URLFile.pool_manager() r = pool.request("GET", url, preload_content=False) if r.status not in (200, 206): diff --git a/openpilot/tools/replay/py_downloader.cc b/openpilot/tools/replay/py_downloader.cc index db2b2be127..a7ab5baa91 100644 --- a/openpilot/tools/replay/py_downloader.cc +++ b/openpilot/tools/replay/py_downloader.cc @@ -5,6 +5,10 @@ #include #include #include +#include +#ifdef __APPLE__ +#include +#endif #include #include #include @@ -27,7 +31,7 @@ void reportProgress(const char *line) { // Run a Python command and capture stdout. Stderr is scanned for PROGRESS lines and otherwise passed // through to the parent's stderr. Returns stdout content. If abort is signaled, kills the child process. std::string runPython(const std::vector &args, std::atomic *abort = nullptr) { - // Build argv for execvp + // Build argv for the downloader module std::vector argv; argv.push_back("python3"); argv.push_back("-m"); @@ -37,50 +41,71 @@ std::string runPython(const std::vector &args, std::atomic *a } argv.push_back(nullptr); + auto open_pipe = [](int (&fds)[2]) { +#ifdef __linux__ + return pipe2(fds, O_CLOEXEC); +#else + if (pipe(fds) != 0) return -1; + if (fcntl(fds[0], F_SETFD, FD_CLOEXEC) == 0 && fcntl(fds[1], F_SETFD, FD_CLOEXEC) == 0) return 0; + close(fds[0]); close(fds[1]); + return -1; +#endif + }; int stdout_pipe[2], stderr_pipe[2]; - if (pipe(stdout_pipe) != 0) { + if (open_pipe(stdout_pipe) != 0) { rWarning("py_downloader: pipe() failed"); return {}; } - if (pipe(stderr_pipe) != 0) { + if (open_pipe(stderr_pipe) != 0) { rWarning("py_downloader: pipe() failed"); close(stdout_pipe[0]); close(stdout_pipe[1]); return {}; } - pid_t pid = fork(); - if (pid < 0) { - rWarning("py_downloader: fork() failed"); + // Avoid copying the large replay address space and running atfork handlers on + // every segment download: both can stall rendering even from a worker thread. + std::vector environment; +#ifdef __APPLE__ + char **parent_environment = *_NSGetEnviron(); +#else + char **parent_environment = environ; +#endif + for (char **entry = parent_environment; *entry; ++entry) { + if (strncmp(*entry, "OPENPILOT_PREFIX=", 17) != 0) environment.emplace_back(*entry); + } + std::vector envp; + for (auto &entry : environment) envp.push_back(entry.data()); + envp.push_back(nullptr); + + posix_spawn_file_actions_t actions; + posix_spawnattr_t attributes; + int error = posix_spawn_file_actions_init(&actions); + const bool actions_initialized = error == 0; + if (!error) error = posix_spawnattr_init(&attributes); + const bool attributes_initialized = error == 0; + if (!error) error = posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0); + if (!error) error = posix_spawn_file_actions_adddup2(&actions, stdout_pipe[1], STDOUT_FILENO); + if (!error) error = posix_spawn_file_actions_adddup2(&actions, stderr_pipe[1], STDERR_FILENO); + for (int fd : {stdout_pipe[0], stdout_pipe[1], stderr_pipe[0], stderr_pipe[1]}) { + if (!error) error = posix_spawn_file_actions_addclose(&actions, fd); + } +#ifdef POSIX_SPAWN_SETSID + if (!error) error = posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETSID); +#else + if (!error) error = posix_spawnattr_setpgroup(&attributes, 0); + if (!error) error = posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETPGROUP); +#endif + pid_t pid = -1; + if (!error) error = posix_spawnp(&pid, "python3", &actions, &attributes, const_cast(argv.data()), envp.data()); + if (attributes_initialized) posix_spawnattr_destroy(&attributes); + if (actions_initialized) posix_spawn_file_actions_destroy(&actions); + if (error) { + rWarning("py_downloader: posix_spawnp() failed: %s", strerror(error)); close(stdout_pipe[0]); close(stdout_pipe[1]); close(stderr_pipe[0]); close(stderr_pipe[1]); return {}; } - if (pid == 0) { - // Child process — detach from controlling terminal so Python - // cannot corrupt terminal settings needed by ncurses in the parent. - setsid(); - int devnull = open("/dev/null", O_RDONLY); - if (devnull >= 0) { - dup2(devnull, STDIN_FILENO); - if (devnull > STDERR_FILENO) close(devnull); - } - - // Clear OPENPILOT_PREFIX so the Python process uses default paths - // (e.g. ~/.comma/auth.json). The prefix is only for IPC in the parent. - unsetenv("OPENPILOT_PREFIX"); - - close(stdout_pipe[0]); - dup2(stdout_pipe[1], STDOUT_FILENO); - close(stdout_pipe[1]); - close(stderr_pipe[0]); - dup2(stderr_pipe[1], STDERR_FILENO); - close(stderr_pipe[1]); - - execvp("python3", const_cast(argv.data())); - _exit(127); - } - // Parent process close(stdout_pipe[1]); close(stderr_pipe[1]); From cb70ba89aa41b78d1cf56f6e2be93210cb5ee1c9 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:50:13 -0700 Subject: [PATCH 022/270] cabana: smooth startup layout (#38794) * cabana: smooth startup layout * cabana: remove camera tab startup comment * cabana: initialize dock geometry on the first frame * cabana: remove route loading placeholder --- openpilot/tools/cabana/ui/app.cc | 4 +++ openpilot/tools/cabana/ui/mainwin.cc | 29 ++++++++++++------- .../tools/cabana/ui/widgets/cameraview.cc | 2 +- .../tools/cabana/ui/widgets/cameraview.h | 2 ++ .../tools/cabana/ui/widgets/videowidget.cc | 14 +++++++-- 5 files changed, 37 insertions(+), 14 deletions(-) diff --git a/openpilot/tools/cabana/ui/app.cc b/openpilot/tools/cabana/ui/app.cc index ce4678f223..3f4dac1016 100644 --- a/openpilot/tools/cabana/ui/app.cc +++ b/openpilot/tools/cabana/ui/app.cc @@ -132,6 +132,8 @@ public: #ifdef __APPLE__ glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); #endif + // Restore geometry and render the initial layout before mapping the window. + glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE); window_ = glfwCreateWindow(1600, 900, "Cabana", nullptr, nullptr); if (window_ == nullptr) { glfwTerminate(); @@ -212,6 +214,8 @@ int run(std::unique_ptr stream, StreamLoader stream_loader, cons inistate::applyWindowGeometry(glfw.window()); MainWindow win(glfw.window(), std::move(stream), std::move(stream_loader), dbc_file); + renderFrame(glfw.window(), &win); + glfwShowWindow(glfw.window()); while (!win.exited()) { if (g_signal_exit.exchange(false)) { printf("\nexiting...\n"); diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index e5490432a2..d78de4be34 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -184,7 +184,7 @@ void MainWindow::drawMenuBar() { if (ImGui::MenuItem("Full Screen", "Ctrl+F11")) toggleFullScreen(); ImGui::Separator(); ImGui::MenuItem(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); - ImGui::MenuItem(video_dock_title_.empty() ? "##video_dock" : video_dock_title_.c_str(), nullptr, &video_visible_); + ImGui::MenuItem(video_dock_title_.empty() ? "Video" : video_dock_title_.c_str(), nullptr, &video_visible_); ImGui::Separator(); if (ImGui::MenuItem("Reset Window Layout")) { messages_visible_ = video_visible_ = true; @@ -789,8 +789,10 @@ void MainWindow::drawWaitDialog() { void MainWindow::drawDockspace() { const ImGuiViewport *viewport = ImGui::GetMainViewport(); - ImGui::SetNextWindowPos(viewport->WorkPos); - ImGui::SetNextWindowSize(viewport->WorkSize); + // Use the menu bar's current-frame reservation, including on the first frame. + const ImRect work_rect = static_cast(viewport)->GetBuildWorkRect(); + ImGui::SetNextWindowPos(work_rect.Min); + ImGui::SetNextWindowSize(work_rect.GetSize()); ImGui::SetNextWindowViewport(viewport->ID); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); @@ -813,6 +815,7 @@ void MainWindow::drawDockspace() { // messages left, video (with charts) right, center widget in the middle ImGui::DockBuilderRemoveNode(dock_id); ImGui::DockBuilderAddNode(dock_id, ImGuiDockNodeFlags_DockSpace); + ImGui::DockBuilderSetNodePos(dock_id, ImGui::GetCursorScreenPos()); ImGui::DockBuilderSetNodeSize(dock_id, dock_size); ImGuiID center = dock_id, left = 0, right = 0; ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.28f, &left, ¢er); @@ -856,11 +859,13 @@ bool beginPanel(const char *name, bool *open, ImGuiWindowFlags flags = 0) { } // namespace void MainWindow::drawMessagesPanel() { - const std::string name = messages_widget_->title() + MESSAGES_PANEL_ID; + const std::string name = (messages_widget_ ? messages_widget_->title() : "MESSAGES") + std::string(MESSAGES_PANEL_ID); setNextPanelClass(); if (beginPanel(name.c_str(), &messages_visible_)) { - help_overlay_.add(messages_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); - messages_widget_->draw(); + if (messages_widget_) { + help_overlay_.add(messages_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + messages_widget_->draw(); + } } const bool floating = floatingOut(); ImGui::End(); @@ -868,13 +873,13 @@ void MainWindow::drawMessagesPanel() { } void MainWindow::drawVideoPanel() { - const std::string name = video_dock_title_ + VIDEO_PANEL; + const std::string name = (video_dock_title_.empty() ? "Video" : video_dock_title_) + VIDEO_PANEL; setNextPanelClass(); const bool video_open = beginPanel(name.c_str(), &video_visible_); const bool floating = floatingOut(); - if (!video_open) { + if (video_widget_ && !video_open) { video_widget_->setVisible(false); // the dock is collapsed or tabbed behind another one, like hideEvent - } else { + } else if (video_widget_) { const ImVec2 avail = ImGui::GetContentRegionAvail(); const bool live = can->liveStreaming(); // the bordered child pads its content, so the heights the widget asks for grow by the padding @@ -962,9 +967,11 @@ void MainWindow::draw() { ImGui::EndChild(); } ImGui::End(); - if (messages_widget_ && messages_visible_) drawMessagesPanel(); + // Submit the same dock windows while loading, so ImGui doesn't collapse their + // nodes and then redistribute the layout when the stream's widgets arrive. + if (messages_visible_) drawMessagesPanel(); if (video_widget_ && !video_visible_) video_widget_->setVisible(false); - if (video_widget_ && video_visible_) drawVideoPanel(); + if (video_visible_) drawVideoPanel(); if (charts_widget_ && charts_floating_) { bool open = true; ImGui::SetNextWindowSize(ImGui::GetMainViewport()->WorkSize, ImGuiCond_Appearing); diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.cc b/openpilot/tools/cabana/ui/widgets/cameraview.cc index 1f730344d5..460ef47c25 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.cc +++ b/openpilot/tools/cabana/ui/widgets/cameraview.cc @@ -95,7 +95,7 @@ float CameraWidget::frameAspectRatio() const { if (frame_texture_.width > 0 && frame_texture_.height > 0) { return (float)frame_texture_.width / frame_texture_.height; } - return 1928.0f / 1208.0f; // the road camera, until the first frame arrives + return DEFAULT_CAMERA_ASPECT_RATIO; // the road camera, until the first frame arrives } void CameraWidget::paint() { diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.h b/openpilot/tools/cabana/ui/widgets/cameraview.h index 2812aea86e..a90565a0f8 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.h +++ b/openpilot/tools/cabana/ui/widgets/cameraview.h @@ -18,6 +18,8 @@ #include "tools/cabana/core/observable.h" #include "msgq/visionipc/visionipc_client.h" +constexpr float DEFAULT_CAMERA_ASPECT_RATIO = 1928.0f / 1208.0f; + // Center-crop the source to fill the destination without stretching. inline ImVec2 videoFillUv(const ImVec2 &size, float aspect_ratio) { const float ratio = size.x / size.y / aspect_ratio; diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 91b41184c0..ce8fb64c04 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -261,6 +261,16 @@ void VideoWidget::createCameraWidget() { if (index != -1) cam_widget_->setStreamType((VisionStreamType)camera_tab_->tabData(index)); })); connections_.push_back(static_cast(can)->qLogLoaded.connect([this](std::shared_ptr qlog) { cam_widget_->parseQLog(qlog); })); + + if (auto *replay = getReplay(); replay && !replay->hasFlag(REPLAY_FLAG_NO_VIPC)) { + std::set streams; + for (const auto &[num, segment] : replay->route().segments()) { + if (!segment.narrow_road_cam.empty() || !segment.qcamera.empty()) streams.insert(VISION_STREAM_NARROW_ROAD); + if (replay->hasFlag(REPLAY_FLAG_CABIN_CAMERA) && !segment.cabin_cam.empty()) streams.insert(VISION_STREAM_CABIN); + if (replay->hasFlag(REPLAY_FLAG_WIDE_ROAD) && !segment.wide_road_cam.empty()) streams.insert(VISION_STREAM_WIDE_ROAD); + } + vipcAvailableStreamsUpdated(streams); + } } void VideoWidget::drawCameraWidget() { @@ -344,10 +354,10 @@ float VideoWidget::sizeHintHeight() const { return MIN_VIDEO_HEIGHT + SLIDER_HEIGHT + toolbarHeight(); } -// the video pane opens with the camera at its natural aspect ratio, filling the width of the dock +// Keep the pane's default proportions stable as frames arrive or cameras change. float VideoWidget::defaultHeight(float width) const { if (!cam_widget_) return toolbarHeight(); // live streams have no camera or slider - const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, width / cam_widget_->frameAspectRatio()); + const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, width / DEFAULT_CAMERA_ASPECT_RATIO); const float tab_height = camera_tab_->count() >= 2 ? ImGui::GetFrameHeight() : 0.0f; return cam_height + tab_height + SLIDER_HEIGHT + toolbarHeight(); } From 9c02eebb72800d0707deddd404c2ba9d812ef77d Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:05:26 -0700 Subject: [PATCH 023/270] cabana: fix clipped control outlines and filter sizing (#38795) * cabana: fix clipped control outlines and filter sizing * cabana: remove added comments * cabana: document control helper API usage --- openpilot/tools/cabana/ui/chart/chart.cc | 2 +- openpilot/tools/cabana/ui/util.cc | 15 ++++++++++++++- openpilot/tools/cabana/ui/util.h | 6 +++++- openpilot/tools/cabana/ui/widgets/historylog.cc | 7 +++++-- .../tools/cabana/ui/widgets/messageswidget.cc | 3 +-- openpilot/tools/cabana/ui/widgets/signalview.cc | 4 +--- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 399304186e..169a588eaa 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -139,7 +139,7 @@ void ChartView::updateLayout() { const ImVec2 top_left = layout_.rect.Min + ImVec2(LAYOUT_MARGINS.x, LAYOUT_MARGINS.y); layout_.move_icon_rect = ImRect(top_left, top_left + grip); const ImVec2 btn_size(iconButtonWidth(), iconButtonWidth()); - const ImVec2 close_min(layout_.rect.Max.x - LAYOUT_MARGINS.z - btn_size.x, top_left.y); + const ImVec2 close_min(layout_.rect.Max.x - std::max(LAYOUT_MARGINS.z, CONTROL_OUTLINE_PADDING) - btn_size.x, top_left.y); layout_.close_btn_rect = ImRect(close_min, close_min + btn_size); const ImVec2 manage_min(close_min.x - btn_size.x - ImGui::GetStyle().ItemInnerSpacing.x, top_left.y); layout_.manage_btn_rect = ImRect(manage_min, manage_min + btn_size); diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index f646f33da4..6269bd11c3 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -62,9 +62,21 @@ bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, I inputCallback, &ctx); } +bool beginControlChild(const char *id, const ImVec2 &size, ImGuiWindowFlags flags) { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(CONTROL_OUTLINE_PADDING, CONTROL_OUTLINE_PADDING)); + const bool visible = ImGui::BeginChild(id, size, ImGuiChildFlags_AlwaysUseWindowPadding, flags); + ImGui::PopStyleVar(); + return visible; +} + bool clearableInput(const char *label, std::string *s, const char *hint, ImGuiInputTextCallback validator) { + const float width = ImGui::CalcItemWidth(); + const float clear_width = iconButtonWidth() + ImGui::GetStyle().ItemInnerSpacing.x; + const bool show_clear = !s->empty() && width >= clear_width + ImGui::GetFrameHeight(); + ImGui::SetNextItemWidth(show_clear ? width - clear_width : width); + ImGui::BeginGroup(); bool changed = validatedInput(label, s, validator, hint); - if (!s->empty()) { + if (show_clear) { ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); ImGui::PushID(label); if (iconButton("clear", icon::X_LG)) { @@ -73,6 +85,7 @@ bool clearableInput(const char *label, std::string *s, const char *hint, ImGuiIn } ImGui::PopID(); } + ImGui::EndGroup(); return changed; } diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index 87afb28f74..98c814b6c4 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -31,7 +31,11 @@ inline bool inputText(const char *label, std::string *s, const char *hint = "", bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, ImGuiInputTextFlags flags = 0); -// an input with a trailing clear button once it holds text; true when the text changed +constexpr float CONTROL_OUTLINE_PADDING = 1.0f; +// Always pair with ImGui::EndChild(), even when false is returned. +bool beginControlChild(const char *id, const ImVec2 &size, ImGuiWindowFlags flags = 0); + +// SetNextItemWidth includes the field and clear button. Returns true when text changes. bool clearableInput(const char *label, std::string *s, const char *hint = "", ImGuiInputTextCallback validator = nullptr); bool comboBox(const char *label, int *index, const std::vector &items); diff --git a/openpilot/tools/cabana/ui/widgets/historylog.cc b/openpilot/tools/cabana/ui/widgets/historylog.cc index ba51c4d6ee..19ce2c9f65 100644 --- a/openpilot/tools/cabana/ui/widgets/historylog.cc +++ b/openpilot/tools/cabana/ui/widgets/historylog.cc @@ -132,11 +132,13 @@ void LogsWidget::exportToCSV() { void LogsWidget::draw() { const ImGuiStyle &style = ImGui::GetStyle(); + beginControlChild("toolbar", ImVec2(0, ImGui::GetFrameHeight() + CONTROL_OUTLINE_PADDING * 2), + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + // toolbar: the export button is right aligned and never clipped, the value input shrinks first const float export_w = iconButtonWidth(); if (!sigs_.empty()) { - const float clear_w = value_edit_.empty() ? 0.0f : iconButtonWidth(); - const float fixed = DISPLAY_TYPE_WIDTH + SIGNALS_WIDTH + COMPARE_WIDTH + clear_w + style.ItemSpacing.x * 4 + export_w; + const float fixed = DISPLAY_TYPE_WIDTH + SIGNALS_WIDTH + COMPARE_WIDTH + style.ItemSpacing.x * 4 + export_w; const float value_w = std::clamp(ImGui::GetContentRegionAvail().x - fixed, 30.0f, 120.0f); ImGui::SetNextItemWidth(DISPLAY_TYPE_WIDTH); @@ -169,6 +171,7 @@ void LogsWidget::draw() { if (iconButton("export_csv", icon::FILETYPE_CSV)) exportToCSV(); ImGui::EndDisabled(); disabledItemTooltip("Export to CSV file..."); + ImGui::EndChild(); ImGui::Separator(); drawTable(); diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc index 6de1735d79..08148107a0 100644 --- a/openpilot/tools/cabana/ui/widgets/messageswidget.cc +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -434,12 +434,11 @@ void MessagesWidget::drawHeader() { } // the filter editors under the header - const float clear_width = iconButtonWidth(); ImGui::TableNextRow(); for (int i = 0; i < MessageList::COLUMN_COUNT; i++) { if (!ImGui::TableSetColumnIndex(i)) continue; ImGui::PushID(i); - ImGui::SetNextItemWidth(filters_[i].empty() ? -FLT_MIN : std::max(1.0f, ImGui::GetContentRegionAvail().x - clear_width)); + ImGui::SetNextItemWidth(-FLT_MIN); const std::string placeholder = std::string("Filter ") + COLUMN_TITLES[i]; if (clearableInput("##filter", &filters_[i], placeholder.c_str())) { std::map filters; diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index c0ed091568..2413912ea2 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -737,10 +737,8 @@ void SignalView::collapseAll() { void SignalView::drawTree() { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); const float min_height = std::max(ImGui::GetContentRegionAvail().y, 300.0f); - const bool visible = ImGui::BeginChild("tree", ImVec2(0, min_height), ImGuiChildFlags_None); - ImGui::PopStyleVar(); + const bool visible = beginControlChild("tree", ImVec2(0, min_height)); if (visible) { DrawContext ctx{ImGui::GetWindowDrawList(), ImGui::GetCursorScreenPos().x, ImGui::GetContentRegionAvail().x, rowHeight()}; // the press that closes an open editor is consumed by the focus change, the index widgets never see it From 0f9ec7158b82f962515136372c42e1ff20dedd07 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:32:22 -0700 Subject: [PATCH 024/270] cabana: validate message IDs and include recorded CAN messages (#38799) --- openpilot/tools/cabana/core/message_id.h | 17 +++++++++++++---- openpilot/tools/cabana/tests/test_cabana.cc | 13 +++++++++++++ .../tools/cabana/ui/chart/signalselector.cc | 5 ++++- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/openpilot/tools/cabana/core/message_id.h b/openpilot/tools/cabana/core/message_id.h index ecac279631..e7f4e3c0b4 100644 --- a/openpilot/tools/cabana/core/message_id.h +++ b/openpilot/tools/cabana/core/message_id.h @@ -1,7 +1,9 @@ #pragma once +#include #include #include #include +#include #include #include @@ -11,11 +13,18 @@ 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))}; + // strict "bus:HEX" parser + static std::optional parse(const std::string &s) { + const auto colon = s.find(':'); + if (colon == std::string::npos) return std::nullopt; + const char *begin = s.data(), *end = begin + s.size(); + uint32_t source = 0, address = 0; + auto bus = std::from_chars(begin, begin + colon, source); + auto addr = std::from_chars(begin + colon + 1, end, address, 16); + if (bus.ec != std::errc() || bus.ptr != begin + colon || source > 255 || addr.ec != std::errc() || addr.ptr != end) return std::nullopt; + return MessageId{static_cast(source), address}; } + static MessageId fromString(const std::string &s) { return parse(s).value_or(MessageId{}); } 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); } diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index e1c98fcc8e..983a80a459 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -15,6 +15,18 @@ const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; +void test_message_id_parsing() { + for (const auto &text : {"", "1", ":123", "1:", "-1:123", "256:1", "1:100000000", "1:1junk", "1junk:1", "1:1:1"}) { + REQUIRE(!MessageId::parse(text)); + REQUIRE(MessageId::fromString(text) == MessageId{}); + } + const MessageId expected{255, 0xffffffff}; + REQUIRE(MessageId::parse("255:FFFFFFFF") == expected); + REQUIRE(MessageId::parse("255:ffffffff") == expected); + REQUIRE(MessageId::parse(expected.toString()) == expected); + REQUIRE(MessageId::parse("0:0") == MessageId{}); +} + void test_generate_dbc() { std::string fn = std::string(OPENDBC_FILE_PATH) + "/tesla_can.dbc"; DBCFile dbc_origin(fn); @@ -381,6 +393,7 @@ void test_cabana_core() { test_pixel_envelope(); test_format_seconds(); test_to_hex(); + test_message_id_parsing(); test_signal_tooltip(); test_generate_dbc(); test_comment_order(); diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc index cab1de0816..2733a65f33 100644 --- a/openpilot/tools/cabana/ui/chart/signalselector.cc +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -11,7 +11,10 @@ #include "tools/cabana/utils/strings.h" SignalSelector::SignalSelector(std::string title) : title_(std::move(title)) { - for (const auto &[id, _] : can->lastMessages()) { + std::set ids; + for (const auto &[id, _] : can->eventsMap()) ids.insert(id); + for (const auto &[id, _] : can->lastMessages()) ids.insert(id); + for (const auto &id : ids) { if (auto m = dbc()->msg(id)) { msgs_combo_.push_back({m->name + " (" + id.toString() + ")", id}); } From 624d5b999517090325999a67466327cd4a20d055 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:33:14 -0700 Subject: [PATCH 025/270] cabana: fix stream and replay lifetimes (#38796) --- .../tools/cabana/streams/abstractstream.h | 2 +- .../tools/cabana/streams/replaystream.cc | 1 + openpilot/tools/cabana/tests/test_cabana.cc | 25 +++++++++++++++++++ openpilot/tools/cabana/ui/threadpool.h | 12 ++++++--- openpilot/tools/replay/replay.cc | 18 +++++++------ openpilot/tools/replay/replay.h | 2 ++ openpilot/tools/replay/seg_mgr.cc | 4 +++ openpilot/tools/replay/seg_mgr.h | 1 + 8 files changed, 54 insertions(+), 11 deletions(-) diff --git a/openpilot/tools/cabana/streams/abstractstream.h b/openpilot/tools/cabana/streams/abstractstream.h index dedf9a2da6..45a9af92f4 100644 --- a/openpilot/tools/cabana/streams/abstractstream.h +++ b/openpilot/tools/cabana/streams/abstractstream.h @@ -42,7 +42,7 @@ public: inline double currentSec() const { return current_sec_; } inline uint64_t toMonoTime(double sec) const { return beginMonoTime() + std::max(sec, 0.0) * 1e9; } - inline double toSeconds(uint64_t mono_time) const { return std::max(0.0, (mono_time - beginMonoTime()) / 1e9); } + inline double toSeconds(uint64_t mono_time) const { return mono_time > beginMonoTime() ? (mono_time - beginMonoTime()) / 1e9 : 0.0; } inline const std::unordered_map &lastMessages() const { return last_msgs; } bool isMessageActive(const MessageId &id) const; diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index fe8048c215..c0ab46abc9 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -19,6 +19,7 @@ ReplayStream::ReplayStream() { ReplayStream::~ReplayStream() { cancelWaits(); + if (replay) replay->stop(); } // runs on replay's merge thread: a segment of CAN data takes ~30 ms to parse and group, which dropped diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 983a80a459..268aa9a86d 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -1,15 +1,20 @@ +#include +#include #include #include #include #include #include +#include +#include #include "common/tests/native_test.h" #include "tools/cabana/dbc/dbcfile.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/routes.h" #include "tools/cabana/ui/qtstate.h" +#include "tools/cabana/ui/threadpool.h" #include "tools/cabana/ui/chart/downsample.h" #include "tools/cabana/utils/strings.h" @@ -366,6 +371,25 @@ void test_qt_state_blobs() { REQUIRE(!qtstate::parseQtHeaderState(fromHex("000000fe00000000000000010000000000000000010000000000000000")).has_value()); } +void test_parallel_failure_joins_workers() { + for (bool fail_on_caller : {true, false}) { + std::atomic finished = 0; + const size_t chunks = std::clamp(std::thread::hardware_concurrency(), 2, 4) + 1; + bool caught = false; + try { + parallelFor(chunks, [&](size_t begin, size_t end) { + if (begin == (fail_on_caller ? 0u : 1u)) throw std::runtime_error("task failed"); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + ++finished; + }); + } catch (const std::runtime_error &) { + caught = true; + } + REQUIRE(caught); + REQUIRE(finished == chunks - 1); + } +} + void test_pixel_envelope() { struct Point { double x, y; @@ -405,6 +429,7 @@ void test_cabana_core() { test_route_timestamps(); test_route_api_response(); test_route_json(); + test_parallel_failure_joins_workers(); test_qt_state_blobs(); } diff --git a/openpilot/tools/cabana/ui/threadpool.h b/openpilot/tools/cabana/ui/threadpool.h index 5a45ec80f2..3580f55c78 100644 --- a/openpilot/tools/cabana/ui/threadpool.h +++ b/openpilot/tools/cabana/ui/threadpool.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -36,7 +37,7 @@ public: stop_ = true; } cv_.notify_all(); - for (auto &t : threads_) t.join(); + for (auto &thread : threads_) thread.join(); } private: @@ -76,6 +77,11 @@ inline void parallelFor(size_t n, const std::function &allow, const std::vec auto event_schema = capnp::Schema::from().asStruct(); sockets_.resize(event_schema.getUnionFields().size(), nullptr); - std::vector active_services; - active_services.reserve(services.size()); + auto &active_services = active_services_; for (const auto &[name, _] : services) { bool is_blocked = std::find(block.begin(), block.end(), name) != block.end(); @@ -55,9 +54,6 @@ void Replay::setupServices(const std::vector &allow, const std::vec std::string services_str = join(active_services, ", "); rInfo("active services: %s", services_str.c_str()); - if (!sm_) { - pm_ = std::make_unique(active_services); - } } void Replay::setupSegmentManager(bool has_filters) { @@ -73,6 +69,15 @@ void Replay::setupSegmentManager(bool has_filters) { } Replay::~Replay() { + stop(); + camera_server_.reset(); + seg_mgr_.reset(); +} + +void Replay::stop() { + // Merge callbacks access both Replay and its owner. Join them while all objects + // and their owning pointers are still alive, before stopping the playback thread. + seg_mgr_->stop(); if (stream_thread_.joinable()) { rInfo("shutdown: in progress..."); interruptStream([this]() { @@ -82,8 +87,6 @@ Replay::~Replay() { stream_thread_.join(); rInfo("shutdown: done"); } - camera_server_.reset(); - seg_mgr_.reset(); } bool Replay::load() { @@ -235,6 +238,7 @@ void Replay::publishMessage(const Event *e) { if (event_filter_ && event_filter_(e)) return; if (!sm_) { + if (!pm_) pm_ = std::make_unique(active_services_); // consumers with an event filter never need one auto bytes = e->data.asBytes(); int ret = pm_->send(sockets_[e->which], (capnp::byte *)bytes.begin(), bytes.size()); if (ret == -1) { diff --git a/openpilot/tools/replay/replay.h b/openpilot/tools/replay/replay.h index 7cd5fbecd0..59e1d67d48 100644 --- a/openpilot/tools/replay/replay.h +++ b/openpilot/tools/replay/replay.h @@ -37,6 +37,7 @@ public: Replay(const std::string &route, std::vector allow, std::vector block, SubMaster *sm = nullptr, uint32_t flags = REPLAY_FLAG_NONE, const std::string &data_dir = "", bool auto_source = false); ~Replay(); + void stop(); bool load(); RouteLoadError lastRouteError() const { return route().lastError(); } void start(int seconds = 0) { seekTo(min_seconds_ + seconds, false); } @@ -107,6 +108,7 @@ private: double min_seconds_ = 0; double max_seconds_ = 0; SubMaster *sm_ = nullptr; + std::vector active_services_; std::unique_ptr pm_; std::vector sockets_; std::unique_ptr camera_server_; diff --git a/openpilot/tools/replay/seg_mgr.cc b/openpilot/tools/replay/seg_mgr.cc index 0778cacbc1..074e7e53db 100644 --- a/openpilot/tools/replay/seg_mgr.cc +++ b/openpilot/tools/replay/seg_mgr.cc @@ -3,6 +3,10 @@ #include SegmentManager::~SegmentManager() { + stop(); +} + +void SegmentManager::stop() { { std::unique_lock lock(mutex_); exit_ = true; diff --git a/openpilot/tools/replay/seg_mgr.h b/openpilot/tools/replay/seg_mgr.h index 54e156fb60..55b412b25a 100644 --- a/openpilot/tools/replay/seg_mgr.h +++ b/openpilot/tools/replay/seg_mgr.h @@ -23,6 +23,7 @@ public: SegmentManager(const std::string &route_name, uint32_t flags, const std::string &data_dir = "", bool auto_source = false) : flags_(flags), route_(route_name, data_dir, auto_source), event_data_(std::make_shared()) {} ~SegmentManager(); + void stop(); bool load(); void setCurrentSegment(int seg_num); From 1bb019521e39d61382d38efa18ded53d6932fafe Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:42:41 -0700 Subject: [PATCH 026/270] cabana: fix chart ranges and control layout (#38797) --- openpilot/tools/cabana/ui/chart/chart.cc | 65 ++++++++----- openpilot/tools/cabana/ui/chart/chart.h | 2 +- .../tools/cabana/ui/chart/chartswidget.cc | 40 +++++--- openpilot/tools/cabana/ui/chart/tiplabel.cc | 96 +++++++++++++------ openpilot/tools/cabana/ui/chart/tiplabel.h | 12 ++- openpilot/tools/cabana/ui/util.cc | 20 ++++ openpilot/tools/cabana/ui/util.h | 2 + 7 files changed, 166 insertions(+), 71 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 169a588eaa..8dc5d627d0 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -6,7 +6,6 @@ #include #include #include -#include #include "tools/cabana/core/settings.h" #include "tools/cabana/settings.h" @@ -139,7 +138,7 @@ void ChartView::updateLayout() { const ImVec2 top_left = layout_.rect.Min + ImVec2(LAYOUT_MARGINS.x, LAYOUT_MARGINS.y); layout_.move_icon_rect = ImRect(top_left, top_left + grip); const ImVec2 btn_size(iconButtonWidth(), iconButtonWidth()); - const ImVec2 close_min(layout_.rect.Max.x - std::max(LAYOUT_MARGINS.z, CONTROL_OUTLINE_PADDING) - btn_size.x, top_left.y); + const ImVec2 close_min(layout_.rect.Max.x - ImGui::GetStyle().WindowPadding.x - btn_size.x, top_left.y); layout_.close_btn_rect = ImRect(close_min, close_min + btn_size); const ImVec2 manage_min(close_min.x - btn_size.x - ImGui::GetStyle().ItemInnerSpacing.x, top_left.y); layout_.manage_btn_rect = ImRect(manage_min, manage_min + btn_size); @@ -150,7 +149,7 @@ void ChartView::updateLayout() { const int marker_size = markerSize(); const int row_height = std::max(marker_size, fm_height) + fm_height + 3; // + the signal value line const int legend_left = layout_.move_icon_rect.Max.x + LEGEND_SPACING; - const int legend_right = std::max(layout_.manage_btn_rect.Min.x - LAYOUT_MARGINS.z, legend_left + 10); + const int legend_right = std::max(layout_.manage_btn_rect.Min.x - ImGui::GetStyle().ItemInnerSpacing.x, legend_left + 10); // layout legend entries left-to-right, wrapping between the move icon and the buttons layout_.legend_rects.clear(); @@ -158,6 +157,9 @@ void ChartView::updateLayout() { for (auto &s : sigs_) { int w = marker_size + LEGEND_SPACING + bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x + ImGui::CalcTextSize(msgLabel(s.msg_id).c_str()).x; + pushMonoFont(font_size); + w = std::max(w, (int)std::ceil(ImGui::CalcTextSize("-0.00000e+000").x)); + popMonoFont(); 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; @@ -262,13 +264,14 @@ void ChartView::updateAxisY() { auto [first, last] = visibleRange(s.vals); s.min = std::numeric_limits::max(); s.max = std::numeric_limits::lowest(); + if (first == last) continue; if (can->liveStreaming()) { for (auto it = first; it != last; ++it) { if (it->y < s.min) s.min = it->y; if (it->y > s.max) s.max = it->y; } } else { - std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last)); + std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last) - 1); } min = std::min(min, s.min); max = std::max(max, s.max); @@ -278,7 +281,8 @@ void ChartView::updateAxisY() { y_unit_ = unit; - double delta = std::abs(max - min) < 1e-3 ? 1 : (max - min) * 0.05; + const double magnitude = std::max(std::abs(min), std::abs(max)); + double delta = max - min <= magnitude * 1e-9 ? (magnitude > 0 ? magnitude * 0.05 : 1) : (max - min) * 0.05; auto [min_y, max_y, tick_count] = getNiceAxisNumbers(min - delta, max + delta, 3); if (min_y != y_min_ || max_y != y_max_) { y_min_ = min_y; @@ -412,10 +416,10 @@ void ChartView::handleMouseRelease() { // Prevent zooming/seeking past the end of the route double min = std::clamp(secondsAtPoint(rubber_rect_.Min), can->minSeconds(), can->maxSeconds()); double max = std::clamp(secondsAtPoint(rubber_rect_.Max), can->minSeconds(), can->maxSeconds()); - if (rubber_rect_.GetWidth() <= 0) { - // no rubber dragged, seek to mouse position + if (rubber_rect_.GetWidth() <= 10) { + // Small movements are still clicks; use the same threshold as drag-to-zoom. can->seekTo(std::clamp(secondsAtPoint(press_pos_), can->minSeconds(), can->maxSeconds())); - } else if (rubber_rect_.GetWidth() > 10 && (max - min) > MIN_ZOOM_SECONDS) { + } else if ((max - min) > MIN_ZOOM_SECONDS) { charts_widget_->zoom_undo_stack_.push(new ZoomCommand({min, max})); } rubber_rect_ = ImRect(); @@ -434,8 +438,8 @@ void ChartView::handleMouseRelease() { void ChartView::takeSignalsFrom(ChartView *source) { for (auto &s : source->sigs_) { + s.color = uniqueColor(s.color); sigs_.push_back(std::move(s)); - sigs_.back().color = uniqueColor(sigs_.back().color, sigs_.back().sig); } source->sigs_.clear(); updateAxisY(); @@ -478,17 +482,17 @@ void ChartView::showTip(double sec) { s.track_pt = *pt; x = std::max(x, xPos(pt->x)); } - std::string name = sigs_.size() > 1 ? s.sig->name + ": " : ""; + std::string name = s.sig->name; std::string min = s.min == std::numeric_limits::max() ? "--" : utils::toString(s.min); std::string max = s.max == std::numeric_limits::lowest() ? "--" : utils::toString(s.max); - text_list.push_back({.has_marker = true, .marker = toImU32(s.color), .name = name, .bold = value, .rest = " (" + min + ", " + max + ")"}); + text_list.push_back({.has_marker = true, .marker = toImU32(s.color), .name = name, .value = value, .min = min, .max = max}); } } if (x < 0) { x = tooltip_x_; } ImVec2 pt(x, layout_.plot_area.Min.y); - text_list.insert(text_list.begin(), TipLine{.name = formatNumber(secondsAtPoint({x, 0}), 2)}); + text_list.insert(text_list.begin(), TipLine{.name = formatNumber(sec, 2) + " s"}); tip_label_.showText(pt, text_list, visible_rect); } @@ -747,6 +751,7 @@ void ChartView::drawTimeline() { } void ChartView::drawSignalValue() { + pushMonoFont(ImGui::GetFontSize()); ImDrawList *painter = ImGui::GetWindowDrawList(); const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); for (int i = 0; i < sigs_.size() && i < layout_.legend_rects.size(); ++i) { @@ -757,27 +762,35 @@ void ChartView::drawSignalValue() { ImRect value_rect(value_min, value_min + layout_.legend_rects[i].GetSize()); float w = ImGui::CalcTextSize(value.c_str()).x; if (w <= value_rect.GetWidth()) { - painter->AddText(ImVec2(value_rect.GetCenter().x - w / 2, value_rect.Min.y), color, value.c_str()); + painter->AddText(value_rect.Min, color, value.c_str()); } else { addTextEllipsis(painter, ImGui::GetFont(), color, value_rect.Min, value_rect.Max.x, value); } } + popMonoFont(); } CabanaColor ChartView::uniqueColor(CabanaColor color, const cabana::Signal *exclude) const { - for (auto &s : sigs_) { - if (s.sig != exclude && std::abs(color.hsv().hue - s.color.hsv().hue) < 0.1) { - // use different color to distinguish it from others. - 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 = CabanaColor::fromHsv(std::fmod(last_color.hsv().hue + 60 / 360.0, 1.0), - sat(rng) / 100.0, - val(rng) / 100.0, - color.a / 255.0f); - break; + auto separation = [&](float hue) { + float distance = 1.0f; + for (const auto &s : sigs_) { + if (exclude && s.sig == exclude) continue; + const float delta = std::abs(hue - s.color.hsv().hue); + distance = std::min(distance, std::min(delta, 1.0f - delta)); + } + return distance; + }; + const float original_hue = color.hsv().hue; + if (separation(original_hue) >= 0.1f) return color; + + float best_hue = original_hue, best_distance = -1; + for (int i = 0; i < 36; ++i) { + const float hue = std::fmod(original_hue + i / 36.0f, 1.0f); + const float distance = separation(hue); + if (distance > best_distance) { + best_hue = hue; + best_distance = distance; } } - return color; + return CabanaColor::fromHsv(best_hue, 0.8f, 0.9f, color.a / 255.0f); } diff --git a/openpilot/tools/cabana/ui/chart/chart.h b/openpilot/tools/cabana/ui/chart/chart.h index fc2b63712c..d32906390a 100644 --- a/openpilot/tools/cabana/ui/chart/chart.h +++ b/openpilot/tools/cabana/ui/chart/chart.h @@ -120,7 +120,7 @@ private: double y_min_ = 0; double y_max_ = 1; int y_tick_count_ = 3; - int y_precision_ = 0; + int y_precision_ = 1; std::string y_unit_; // interaction enum class MouseMode { None, Rubber, Scrub }; diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index d10300cbe8..a5845cefdc 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -206,12 +206,13 @@ void ChartsWidget::drawToolBar() { const std::string range_lb = is_zoomed ? std::string() : utils::formatSeconds(max_chart_range_); std::string reset_zoom_text; if (!is_zoomed) { - items.push_back({ImGui::CalcTextSize(range_lb.c_str()).x, [&range_lb]() { + // the range label and the slider are one unit: drawn inline and moved to the overflow menu together + slider_index = items.size(); + const float label_width = ImGui::CalcTextSize(range_lb.c_str()).x + ImGui::GetStyle().ItemInnerSpacing.x; + items.push_back({label_width + slider_width, [this, &range_lb, &slider_width]() { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(range_lb.c_str()); - }}); - slider_index = items.size(); - items.push_back({slider_width, [this, &slider_width]() { + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); // Restore the slider width in overflow; the toolbar may have shrunk it. const bool in_menu = ImGui::GetCurrentWindow()->Flags & ImGuiWindowFlags_Popup; const float width = in_menu ? std::max(ImGui::GetContentRegionAvail().x, 150.0f) : slider_width; @@ -219,9 +220,17 @@ void ChartsWidget::drawToolBar() { ImGui::SetItemTooltip("Set the chart range"); }}); } else { + const auto &range = *can->timeRange(); char buf[64]; - snprintf(buf, sizeof(buf), "%.2f-%.2f", can->timeRange()->first, can->timeRange()->second); + snprintf(buf, sizeof(buf), "%.2f-%.2f", range.first, range.second); reset_zoom_text = buf; + // The undo/redo/reset buttons form one group. The reset button has a fixed width in the mono font, + // sized for the longest range the stream can show, so its neighbors do not shift as the range changes. + const int digits = std::max({1, (int)std::to_string((long long)can->maxSeconds()).size(), (int)std::to_string((long long)range.second).size()}); + const std::string widest = std::string(digits, '0') + ".00"; + pushMonoFont(ImGui::GetFontSize()); + const float reset_zoom_width = iconTextButtonWidth(icon::ZOOM_OUT, widest + "-" + widest); + popMonoFont(); items.push_back({iconButtonWidth(), [this]() { ImGui::BeginDisabled(!zoom_undo_stack_.canUndo()); if (iconButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); @@ -232,10 +241,15 @@ void ChartsWidget::drawToolBar() { if (iconButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); ImGui::EndDisabled(); }}); - items.push_back({toolbarButtonWidth(std::string(icon::ZOOM_OUT) + " " + reset_zoom_text), [this, &reset_zoom_text]() { - if (ImGui::Button((std::string(icon::ZOOM_OUT) + " " + reset_zoom_text + "###reset_zoom_btn").c_str())) zoomReset(); + items.back().tight = true; + items.push_back({reset_zoom_width, [this, &reset_zoom_text, reset_zoom_width]() { + pushMonoFont(ImGui::GetFontSize()); + const bool clicked = iconTextButton("reset_zoom_btn", icon::ZOOM_OUT, reset_zoom_text, reset_zoom_width); + popMonoFont(); + if (clicked) zoomReset(); ImGui::SetItemTooltip("Reset Zoom"); }}); + items.back().tight = true; } items.push_back(toolbarAction("remove_all_btn", icon::TRASH, "Remove all charts", [this]() { removeAll(); }, !charts_.empty())); const char *dock_btn_icon = is_docked_ ? icon::BOX_ARROW_UP_RIGHT : icon::BOX_ARROW_IN_DOWN_LEFT; @@ -247,7 +261,7 @@ void ChartsWidget::drawToolBar() { const float shrink = std::min(slider_width - MIN_RANGE_SLIDER_WIDTH, toolbarWidth(items, spacer_index) - ImGui::GetContentRegionAvail().x); if (shrink > 0.0f) { slider_width -= shrink; - items[slider_index].width = slider_width; + items[slider_index].width -= shrink; } } drawToolbar(items, spacer_index); @@ -273,7 +287,8 @@ ChartView *ChartsWidget::createChart(int pos) { ChartView *ptr = chart.get(); pos = std::clamp(pos, 0, (int)charts_.size()); charts_.insert(charts_.begin() + pos, std::move(chart)); - currentCharts().insert(currentCharts().begin() + pos, ptr); + auto ¤t = currentCharts(); + current.insert(current.begin() + std::min(pos, (int)current.size()), ptr); updateLayout(); return ptr; } @@ -291,8 +306,8 @@ void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, boo void ChartsWidget::splitChart(ChartView *src_chart) { if (src_chart->signals().size() > 1) { - auto it = std::find_if(charts_.begin(), charts_.end(), [src_chart](auto &c) { return c.get() == src_chart; }); - const int pos = it - charts_.begin() + 1; + auto ¤t = currentCharts(); + const int pos = std::find(current.begin(), current.end(), src_chart) - current.begin() + 1; for (auto &s : src_chart->takeExtraSignals()) { createChart(pos)->adoptSignal(std::move(s)); } @@ -606,9 +621,8 @@ void ChartsWidget::draw() { } void ChartsContainer::draw() { - ImGuiWindow *window = ImGui::GetCurrentWindow(); const ImVec2 start = ImGui::GetCursorScreenPos(); - const float width_avail = window->InnerRect.GetWidth() - (window->ScrollbarY ? ImGui::GetStyle().ItemInnerSpacing.x : 0.0f); + const float width_avail = ImGui::GetContentRegionAvail().x; geometry_ = ImRect(start, start + ImVec2(width_avail, 0)); charts_widget_->updateLayout(); diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.cc b/openpilot/tools/cabana/ui/chart/tiplabel.cc index 68889b7886..0efc4e7ad6 100644 --- a/openpilot/tools/cabana/ui/chart/tiplabel.cc +++ b/openpilot/tools/cabana/ui/chart/tiplabel.cc @@ -2,35 +2,49 @@ #include "tools/cabana/ui/chart/tiplabel.h" #include -#include +#include #include "tools/cabana/ui/util.h" ImVec2 TipLabel::layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) const { - ImFont *bold = boldFont(); const float font_size = ImGui::GetFontSize(); - const float line_height = ImGui::GetTextLineHeight(); - ImVec2 size(0, 0); - float y = origin.y; + const float line_height = std::ceil(ImGui::GetTextLineHeight() + 2); + const float marker = std::floor(markerSize()); + const float gap = 8; + const float width = column_widths_[0] + column_widths_[1] + column_widths_[2] + column_widths_[3] + gap * 3; + const ImU32 muted = ImGui::GetColorU32(ImGuiCol_TextDisabled); + float y = std::round(origin.y); + auto draw = [&](float x, const std::string &text, ImU32 color) { + if (p) p->AddText(ImVec2(std::round(x), y), color, text.c_str()); + }; + const char *heading = !text_.empty() && !text_[0].has_marker ? text_[0].name.c_str() : "Signal"; + const char *headers[] = {heading, "Value", "Min", "Max"}; + float x = origin.x; + for (int i = 0; i < 4; ++i) { + draw(i ? x + column_widths_[i] - ImGui::CalcTextSize(headers[i]).x : x, headers[i], muted); + x += column_widths_[i] + gap; + } + y += line_height; + if (p) p->AddLine(ImVec2(origin.x, y - 2), ImVec2(origin.x + width, y - 2), ImGui::GetColorU32(ImGuiCol_Border)); for (const auto &line : text_) { - float x = origin.x; - if (line.has_marker) { - if (p) drawColorMarker(p, ImVec2(x, y), line.marker); - x += markerSize() + 4; + if (!line.has_marker) continue; + if (p) { + const ImVec2 marker_pos(std::round(origin.x), std::round(y + (font_size - marker) * 0.5f)); + p->AddRectFilled(marker_pos, marker_pos + ImVec2(marker, marker), line.marker); } - if (p) p->AddText(ImVec2(x, y), fg, line.name.c_str()); - x += ImGui::CalcTextSize(line.name.c_str()).x; - if (!line.bold.empty()) { - if (p) p->AddText(bold, font_size, ImVec2(x, y), fg, line.bold.c_str()); - x += bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, line.bold.c_str()).x; + if (p) drawElidedText(p, ImRect(ImVec2(origin.x + marker + 6, y), + ImVec2(origin.x + column_widths_[0], y + font_size)), line.name, fg); + x = origin.x + column_widths_[0] + gap; + pushMonoFont(font_size); + const std::string *values[] = {&line.value, &line.min, &line.max}; + for (int i = 0; i < 3; ++i) { + draw(x + column_widths_[i + 1] - ImGui::CalcTextSize(values[i]->c_str()).x, *values[i], i == 0 ? fg : muted); + x += column_widths_[i + 1] + gap; } - if (p) p->AddText(ImVec2(x, y), fg, line.rest.c_str()); - x += ImGui::CalcTextSize(line.rest.c_str()).x; - size.x = std::max(size.x, x - origin.x); + popMonoFont(); y += line_height; } - size.y = y - origin.y; - return size; + return ImVec2(width, y - origin.y); } ImVec2 TipLabel::sizeHint() const { @@ -38,25 +52,53 @@ ImVec2 TipLabel::sizeHint() const { } void TipLabel::showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect) { + bool same_signals = text.size() == text_.size(); + for (size_t i = 1; same_signals && i < text.size(); ++i) same_signals = text[i].name == text_[i].name; + if (!same_signals) column_widths_ = {}; text_ = text; + anchor_ = pt; + area_ = rect; + visible_ = !text_.empty(); +} + +void TipLabel::updateLayout() { + // Playback notifications can update the text outside an ImGui window scope. + // Measure and position it only while the owning chart is being drawn. + column_widths_[0] = std::max(column_widths_[0], ImGui::CalcTextSize(text_.empty() ? "Signal" : text_[0].name.c_str()).x); + for (const auto &line : text_) { + if (line.has_marker) column_widths_[0] = std::max(column_widths_[0], std::ceil(markerSize() + 6 + ImGui::CalcTextSize(line.name.c_str()).x)); + } + pushMonoFont(ImGui::GetFontSize()); + const float number_width = std::ceil(ImGui::CalcTextSize("-0.00000").x); + for (int i = 1; i < 4; ++i) column_widths_[i] = std::max(column_widths_[i], number_width); + for (const auto &line : text_) { + const std::string *values[] = {&line.value, &line.min, &line.max}; + for (int i = 0; i < 3; ++i) column_widths_[i + 1] = std::max(column_widths_[i + 1], std::ceil(ImGui::CalcTextSize(values[i]->c_str()).x)); + } + popMonoFont(); + const ImGuiViewport *viewport = ImGui::GetWindowViewport(); + const ImRect bounds(viewport->WorkPos, viewport->WorkPos + viewport->WorkSize); + const float numeric_width = column_widths_[1] + column_widths_[2] + column_widths_[3] + 24 + MARGIN * 2 + 1; + column_widths_[0] = std::min(column_widths_[0], std::max(40.0f, std::min(ImGui::GetFontSize() * 16, bounds.GetWidth() - numeric_width))); if (!text_.empty()) { ImVec2 extra(1, 1); size_ = sizeHint() + extra; - ImVec2 tip_pos(pt.x + 8, rect.Min.y + 2); - if (tip_pos.x + size_.x >= rect.Max.x) { - tip_pos.x = pt.x - size_.x - 8; - } - if (rect.Contains(ImRect(tip_pos, tip_pos + size_))) { - pos_ = tip_pos; - visible_ = true; - return; + ImVec2 tip_pos(anchor_.x + 8, area_.Min.y + 2); + if (anchor_.x >= area_.GetCenter().x) { + tip_pos.x = anchor_.x - size_.x - 8; } + tip_pos.x = std::clamp(tip_pos.x, bounds.Min.x, std::max(bounds.Min.x, bounds.Max.x - size_.x)); + tip_pos.y = std::clamp(tip_pos.y, bounds.Min.y, std::max(bounds.Min.y, bounds.Max.y - size_.y)); + pos_ = tip_pos; + visible_ = true; + return; } visible_ = false; } void TipLabel::draw() { if (!visible_) return; + updateLayout(); ImDrawList *p = ImGui::GetForegroundDrawList(); // filled panel with a 1px frame diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.h b/openpilot/tools/cabana/ui/chart/tiplabel.h index 6aa80538e8..fe7b099d37 100644 --- a/openpilot/tools/cabana/ui/chart/tiplabel.h +++ b/openpilot/tools/cabana/ui/chart/tiplabel.h @@ -1,18 +1,18 @@ #pragma once +#include #include #include #include "imgui.h" #include "imgui_internal.h" -// one line of the tip: [square] name value (min, max) +// A signal row, or a time heading when has_marker is false. struct TipLine { bool has_marker = false; ImU32 marker = 0; std::string name; - std::string bold; - std::string rest; + std::string value, min, max; }; class TipLabel { @@ -26,9 +26,13 @@ private: // lays the lines out from origin, drawing them when p is given; returns the size of the text block ImVec2 layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) const; ImVec2 sizeHint() const; + void updateLayout(); - static constexpr float MARGIN = 2.0f; // 1 + PM_ToolTipLabelFrameWidth + static constexpr float MARGIN = 6.0f; std::vector text_; + std::array column_widths_{}; + ImVec2 anchor_; + ImRect area_; ImVec2 pos_; ImVec2 size_; bool visible_ = false; diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 6269bd11c3..30b6dc2c76 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -214,6 +214,26 @@ bool iconButton(const char *id, const char *icon, const char *tooltip) { return clicked; } +float iconTextButtonWidth(const char *icon, const std::string &text) { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::CalcTextSize(icon).x + style.ItemInnerSpacing.x + ImGui::CalcTextSize(text.c_str(), nullptr, true).x + style.FramePadding.x * 2; +} + +bool iconTextButton(const char *id, const char *icon, const std::string &text, float width) { + const ImGuiStyle &style = ImGui::GetStyle(); + if (width <= 0.0f) width = iconTextButtonWidth(icon, text); + const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(width, 0.0f)); + const ImVec2 min = ImGui::GetItemRectMin(), max = ImGui::GetItemRectMax(); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); + auto *draw_list = ImGui::GetWindowDrawList(); + draw_list->AddText(ImVec2(min.x + style.FramePadding.x, min.y + style.FramePadding.y), color, icon); + // the text is centered between the icon and the right padding + const float left = min.x + style.FramePadding.x + ImGui::CalcTextSize(icon).x + style.ItemInnerSpacing.x; + const float slack = max.x - style.FramePadding.x - left - ImGui::CalcTextSize(text.c_str(), nullptr, true).x; + draw_list->AddText(ImVec2(left + std::max(0.0f, slack * 0.5f), min.y + style.FramePadding.y), color, text.c_str()); + return clicked; +} + void disabledItemTooltip(const char *text) { if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip | ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", text); } diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index 98c814b6c4..576f197e88 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -74,6 +74,8 @@ int nonWhitespaceValidator(ImGuiInputTextCallbackData *data); // Use ItemInnerSpacing between related buttons and ItemSpacing between groups. bool iconButton(const char *id, const char *icon, const char *tooltip = nullptr); float iconButtonWidth(); +bool iconTextButton(const char *id, const char *icon, const std::string &text, float width = 0.0f); +float iconTextButtonWidth(const char *icon, const std::string &text); // tooltip for the last item that also shows while the item is disabled void disabledItemTooltip(const char *text); From 2b4d050e9c0ef1442fdbd3018949911cc7b39855 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:43:41 -0700 Subject: [PATCH 027/270] cabana: fix video startup and desktop interactions (#38798) * cabana: fix video startup and desktop interactions * cabana: remove util comments --- openpilot/tools/cabana/ui/helpoverlay.cc | 18 +++++++++------ openpilot/tools/cabana/ui/helpoverlay.h | 7 +++++- openpilot/tools/cabana/ui/mainwin.cc | 22 ++++++++++--------- openpilot/tools/cabana/ui/util.cc | 11 ++++++++-- openpilot/tools/cabana/ui/util.h | 7 ++++++ .../tools/cabana/ui/widgets/cameraview.h | 3 ++- .../tools/cabana/ui/widgets/videowidget.h | 2 +- 7 files changed, 48 insertions(+), 22 deletions(-) diff --git a/openpilot/tools/cabana/ui/helpoverlay.cc b/openpilot/tools/cabana/ui/helpoverlay.cc index 1fe061a382..bc64f99196 100644 --- a/openpilot/tools/cabana/ui/helpoverlay.cc +++ b/openpilot/tools/cabana/ui/helpoverlay.cc @@ -139,15 +139,19 @@ void HelpOverlay::toggle() { } void HelpOverlay::add(const std::string &text, const ImRect &rect) { - if (visible_) texts_.emplace_back(text, rect); + if (visible_) texts_.push_back({text, rect, ImGui::GetWindowViewport()}); } void HelpOverlay::draw() { if (!visible_) return; - const ImGuiViewport *viewport = ImGui::GetMainViewport(); - ImDrawList *dl = ImGui::GetForegroundDrawList(); - const ImRect work_rect(viewport->WorkPos, ImVec2(viewport->WorkPos.x + viewport->WorkSize.x, viewport->WorkPos.y + viewport->WorkSize.y)); - dl->AddRectFilled(viewport->Pos, ImVec2(viewport->Pos.x + viewport->Size.x, viewport->Pos.y + viewport->Size.y), IM_COL32(0, 0, 0, 50)); + // Each panel belongs to a viewport; detached panels need their own foreground layer. + std::vector viewports{ImGui::GetMainViewport()}; + for (const auto &entry : texts_) { + if (std::find(viewports.begin(), viewports.end(), entry.viewport) == viewports.end()) viewports.push_back(entry.viewport); + } + for (auto *viewport : viewports) { + ImGui::GetForegroundDrawList(viewport)->AddRectFilled(viewport->Pos, ImVec2(viewport->Pos.x + viewport->Size.x, viewport->Pos.y + viewport->Size.y), IM_COL32(0, 0, 0, 50)); + } ImFont *font = ImGui::GetFont(); ImFont *bold_font = boldFont() ? boldFont() : font; const float font_size = ImGui::GetFontSize(); @@ -156,7 +160,8 @@ void HelpOverlay::draw() { if (r.swatch) return font_size; return (r.bold ? bold_font : font)->CalcTextSizeA(font_size, FLT_MAX, 0.0f, r.text.c_str()).x; }; - for (const auto &[raw, rect] : texts_) { + for (const auto &[raw, rect, viewport] : texts_) { + ImDrawList *dl = ImGui::GetForegroundDrawList(viewport); if (raw.empty()) continue; const auto lines = parseHelpHtml(raw); float width = 0; @@ -167,7 +172,6 @@ void HelpOverlay::draw() { } const ImVec2 size(width, lines.size() * line_h); const ImVec2 center((rect.Min.x + rect.Max.x) * 0.5f, (rect.Min.y + rect.Max.y) * 0.5f); - if (!work_rect.Contains(center)) continue; // a torn off panel is in another viewport const ImVec2 min(center.x - size.x * 0.5f - 8.0f, center.y - size.y * 0.5f - 8.0f); const ImVec2 max(center.x + size.x * 0.5f + 8.0f, center.y + size.y * 0.5f + 8.0f); dl->AddRectFilled(min, max, ImGui::GetColorU32(ImGuiCol_PopupBg), ImGui::GetStyle().PopupRounding); diff --git a/openpilot/tools/cabana/ui/helpoverlay.h b/openpilot/tools/cabana/ui/helpoverlay.h index 2639d20946..1431a4c762 100644 --- a/openpilot/tools/cabana/ui/helpoverlay.h +++ b/openpilot/tools/cabana/ui/helpoverlay.h @@ -18,7 +18,12 @@ public: void draw(); private: - std::vector> texts_; + struct Entry { + std::string text; + ImRect rect; + ImGuiViewport *viewport; + }; + std::vector texts_; bool visible_ = false; int opened_frame_ = -1; }; diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index d78de4be34..d720e87c83 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -96,8 +96,8 @@ void MainWindow::drawFileMenu() { if (ImGui::MenuItem("Export to CSV...", nullptr, false, has_stream)) exportToCSV(); ImGui::Separator(); - if (ImGui::MenuItem("New DBC File", "Ctrl+N")) newFile(); - if (ImGui::MenuItem("Open DBC File...", "Ctrl+O")) openFile(); + if (ImGui::MenuItem("New DBC File", shortcut("N").c_str())) newFile(); + if (ImGui::MenuItem("Open DBC File...", shortcut("O").c_str())) openFile(); if (ImGui::BeginMenu("Manage DBC Files", has_stream)) { drawManageDBCsMenu(); @@ -120,8 +120,8 @@ void MainWindow::drawFileMenu() { ImGui::Separator(); const int cnt = dbc()->nonEmptyDBCCount(); const std::string save_text = cnt > 1 ? "Save " + std::to_string(cnt) + " DBCs..." : "Save DBC..."; - if (ImGui::MenuItem(save_text.c_str(), "Ctrl+S", false, cnt > 0)) save(); - if (ImGui::MenuItem("Save DBC As...", "Ctrl+Shift+S", false, cnt == 1)) saveAs(); + if (ImGui::MenuItem(save_text.c_str(), shortcut("S").c_str(), false, cnt > 0)) save(); + if (ImGui::MenuItem("Save DBC As...", shortcut("Shift+S").c_str(), false, cnt == 1)) saveAs(); // TODO: Support clipboard for multiple files if (ImGui::MenuItem("Copy DBC To Clipboard", nullptr, false, cnt == 1)) saveToClipboard(); @@ -129,7 +129,7 @@ void MainWindow::drawFileMenu() { if (ImGui::MenuItem("Settings...")) openSettings(); ImGui::Separator(); - if (ImGui::MenuItem("Exit", "Ctrl+Q")) close(); + if (ImGui::MenuItem("Exit", shortcut("Q").c_str())) close(); } namespace { @@ -175,13 +175,13 @@ void MainWindow::drawMenuBar() { auto stack = UndoStack::instance(); const std::string undo_text = stack->canUndo() ? "Undo " + stack->undoText() : "Undo"; const std::string redo_text = stack->canRedo() ? "Redo " + stack->redoText() : "Redo"; - if (ImGui::MenuItem(undo_text.c_str(), "Ctrl+Z", false, stack->canUndo())) stack->undo(); - if (ImGui::MenuItem(redo_text.c_str(), "Ctrl+Shift+Z", false, stack->canRedo())) stack->redo(); + if (ImGui::MenuItem(undo_text.c_str(), shortcut("Z").c_str(), false, stack->canUndo())) stack->undo(); + if (ImGui::MenuItem(redo_text.c_str(), shortcut("Shift+Z").c_str(), false, stack->canRedo())) stack->redo(); ImGui::EndMenu(); } if (beginTopMenu("View")) { - if (ImGui::MenuItem("Full Screen", "Ctrl+F11")) toggleFullScreen(); + if (ImGui::MenuItem("Full Screen", shortcut("F11").c_str())) toggleFullScreen(); ImGui::Separator(); ImGui::MenuItem(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); ImGui::MenuItem(video_dock_title_.empty() ? "Video" : video_dock_title_.c_str(), nullptr, &video_visible_); @@ -216,7 +216,6 @@ void MainWindow::createDockWidgets() { center_widget_.setChartsWidget(charts_widget_.get()); video_widget_ = std::make_unique(); widget_connections_.push_back(charts_widget_->toggleChartsDocking.connect([this]() { toggleChartsDocking(); })); - widget_connections_.push_back(charts_widget_->showTip.connect([this](double sec) { video_widget_->showThumbnail(sec); })); } void MainWindow::showStatusMessage(const std::string &msg, int timeout_ms) { @@ -976,7 +975,10 @@ void MainWindow::draw() { bool open = true; ImGui::SetNextWindowSize(ImGui::GetMainViewport()->WorkSize, ImGuiCond_Appearing); setNextWindowFloatsOut(); - if (ImGui::Begin(CHARTS_WINDOW, &open, ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) charts_widget_->draw(); + if (ImGui::Begin(CHARTS_WINDOW, &open, ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + charts_widget_->draw(); + } ImGui::End(); if (!open) toggleChartsDocking(); } diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 30b6dc2c76..4566064a27 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -519,7 +519,12 @@ void drawToolbar(const std::vector &items, size_t spacer_index, flo // the extension button sits fully inside the toolbar: its right edge is the content region right edge const float extension_x = std::max(start_x, right_edge - extension_width); visible == 0 ? ImGui::SetCursorPosX(extension_x) : ImGui::SameLine(extension_x); - if (iconButton("toolbar_extension", icon::CHEVRON_DOUBLE_RIGHT, "More")) ImGui::OpenPopup("toolbar_extension_menu"); + const bool extension_open = ImGui::IsPopupOpen("toolbar_extension_menu"); + const bool extension_clicked = iconButton("toolbar_extension", icon::CHEVRON_DOUBLE_RIGHT, "More"); + if (!extension_open && (extension_clicked || + (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)))) { + ImGui::OpenPopup("toolbar_extension_menu"); + } // the popup opens inward: its right edge is aligned with the button so it stays inside the window ImGui::SetNextWindowPos(ImVec2(ImGui::GetItemRectMax().x, ImGui::GetItemRectMax().y), ImGuiCond_Always, ImVec2(1, 0)); if (ImGui::BeginPopup("toolbar_extension_menu")) { @@ -565,7 +570,9 @@ bool menuButton(const char *id, const std::string &text, const char *popup_id, b ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : style.Colors[ImGuiCol_Button]); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(padding_x, style.FramePadding.y)); ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.0f, 0.5f)); - const bool clicked = ImGui::ButtonEx((text + "###" + id).c_str(), ImVec2(width, 0.0f), ImGuiButtonFlags_PressedOnClick); + const bool pressed = ImGui::ButtonEx((text + "###" + id).c_str(), ImVec2(width, 0.0f), ImGuiButtonFlags_PressedOnClick); + const bool clicked = pressed || (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup) && + ImGui::IsMouseClicked(ImGuiMouseButton_Left)); ImGui::PopStyleVar(2); ImGui::PopStyleColor(); if (bold) popBoldFont(); diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index 576f197e88..d7a27fba7b 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -71,6 +71,13 @@ int doubleValidator(ImGuiInputTextCallbackData *data); int ipValidator(ImGuiInputTextCallbackData *data); int nonWhitespaceValidator(ImGuiInputTextCallbackData *data); +#ifdef __APPLE__ +constexpr const char *MOD_KEY = "Cmd"; +#else +constexpr const char *MOD_KEY = "Ctrl"; +#endif +inline std::string shortcut(const char *keys) { return std::string(MOD_KEY) + "+" + keys; } + // Use ItemInnerSpacing between related buttons and ItemSpacing between groups. bool iconButton(const char *id, const char *icon, const char *tooltip = nullptr); float iconButtonWidth(); diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.h b/openpilot/tools/cabana/ui/widgets/cameraview.h index a90565a0f8..000e745f66 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.h +++ b/openpilot/tools/cabana/ui/widgets/cameraview.h @@ -18,7 +18,8 @@ #include "tools/cabana/core/observable.h" #include "msgq/visionipc/visionipc_client.h" -constexpr float DEFAULT_CAMERA_ASPECT_RATIO = 1928.0f / 1208.0f; +// OS04C10 output, also used by its 1344x760 downscaled video. +constexpr float DEFAULT_CAMERA_ASPECT_RATIO = 2688.0f / 1520.0f; // Center-crop the source to fill the destination without stretching. inline ImVec2 videoFillUv(const ImVec2 &size, float aspect_ratio) { diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h index a1f6076a64..f7f0ced062 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.h +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -89,10 +89,10 @@ public: // MainWindow calls this every frame with the video dock visibility, so the camera widget gets its // vipc thread started and stopped void setVisible(bool visible); - void showThumbnail(double seconds); std::string whatsThis() const; private: + void showThumbnail(double seconds); void updateSliderThumbnail(); // the thumbnail follows the mouse over the slider std::string formatTime(double sec, bool include_milliseconds = false); void timeRangeChanged(); From 7c1f2244431330916f8e8031dfa93910d7d9947b Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:03:12 -0700 Subject: [PATCH 028/270] cabana: stack the undo/redo zoom buttons in the chart menu (#38800) --- openpilot/tools/cabana/ui/chart/chartswidget.cc | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index a5845cefdc..16fb7853b1 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -231,17 +231,15 @@ void ChartsWidget::drawToolBar() { pushMonoFont(ImGui::GetFontSize()); const float reset_zoom_width = iconTextButtonWidth(icon::ZOOM_OUT, widest + "-" + widest); popMonoFont(); - items.push_back({iconButtonWidth(), [this]() { + items.push_back({iconButtonWidth() * 2 + ImGui::GetStyle().ItemInnerSpacing.x, [this]() { ImGui::BeginDisabled(!zoom_undo_stack_.canUndo()); if (iconButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); ImGui::EndDisabled(); - }}); - items.push_back({iconButtonWidth(), [this]() { + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); ImGui::BeginDisabled(!zoom_undo_stack_.canRedo()); if (iconButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); ImGui::EndDisabled(); }}); - items.back().tight = true; items.push_back({reset_zoom_width, [this, &reset_zoom_text, reset_zoom_width]() { pushMonoFont(ImGui::GetFontSize()); const bool clicked = iconTextButton("reset_zoom_btn", icon::ZOOM_OUT, reset_zoom_text, reset_zoom_width); From 20ae4ac44d5760e1005d9a818ae5517335c0c4d9 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:08:32 -0700 Subject: [PATCH 029/270] cabana: standardize settings step buttons (#38801) * cabana: align and brighten settings step buttons * cabana: use standard settings icon button color --- .../tools/cabana/ui/dialogs/settingsdialog.cc | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc index ea82a62a8d..f67e2f2ad8 100644 --- a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc @@ -7,6 +7,7 @@ #include "imgui_internal.h" #include "tools/cabana/settings.h" #include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/icons.h" #include "tools/cabana/ui/util.h" #include "tools/cabana/utils/util.h" @@ -32,6 +33,25 @@ void formRow(FormLabel label, float label_width) { ImGui::SetNextItemWidth(-FLT_MIN); } +void settingInputInt(const char *id, int *value, int step, int step_fast, int minimum, int maximum) { + const float spacing = ImGui::GetStyle().ItemInnerSpacing.x; + const float width = ImGui::CalcItemWidth(); + ImGui::PushID(id); + ImGui::BeginGroup(); + ImGui::SetNextItemWidth(width - 2 * (iconButtonWidth() + spacing)); + ImGui::InputInt("##value", value, 0); + *value = std::clamp(*value, minimum, maximum); + const int increment = ImGui::GetIO().KeyCtrl ? step_fast : step; + ImGui::PushItemFlag(ImGuiItemFlags_ButtonRepeat, true); + ImGui::SameLine(0.0f, spacing); + if (iconButton("decrement", icon::DASH_LG)) *value = std::max(minimum, *value - increment); + ImGui::SameLine(0.0f, spacing); + if (iconButton("increment", icon::PLUS_LG)) *value = std::min(maximum, *value + increment); + ImGui::PopItemFlag(); + ImGui::EndGroup(); + ImGui::PopID(); +} + } // namespace void SettingsDialog::open() { @@ -56,10 +76,7 @@ void SettingsDialog::draw() { int theme_index = theme_ - LIGHT_THEME; if (ImGui::Combo("##theme", &theme_index, themes, IM_ARRAYSIZE(themes))) theme_ = theme_index + LIGHT_THEME; formRow(CACHED_MINUTES, label_width); - // InputInt takes no character filter, so out of range text is clamped after the edit - if (ImGui::InputInt("##cached_minutes", &cached_minutes_, 1, 10)) { - cached_minutes_ = std::clamp(cached_minutes_, MIN_CACHE_MINUTES, MAX_CACHE_MINUTES); - } + settingInputInt("cached_minutes", &cached_minutes_, 1, 10, MIN_CACHE_MINUTES, MAX_CACHE_MINUTES); ImGui::SeparatorText("New Signal Settings"); static const char *directions[] = {"MSB First", "LSB First", "Always Little Endian", "Always Big Endian"}; @@ -68,7 +85,7 @@ void SettingsDialog::draw() { ImGui::SeparatorText("Chart"); formRow(CHART_HEIGHT, label_width); - if (ImGui::InputInt("##chart_height", &chart_height_, 10, 10)) chart_height_ = std::clamp(chart_height_, 100, 500); + settingInputInt("chart_height", &chart_height_, 10, 10, 100, 500); checkBox("Enable live stream logging", &log_livestream_); ImGui::BeginDisabled(!log_livestream_); From 3e54af9db8d44ec8a36c6de624a42f8a4ca4953b Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:47:20 -0700 Subject: [PATCH 030/270] cabana: playback controls for live streaming (#38802) * Hide Cabana playback controls during live streaming * cabana: retain pause and go-live controls for live streams --- openpilot/tools/cabana/ui/mainwin.cc | 8 +++--- .../tools/cabana/ui/widgets/videowidget.cc | 25 ++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index d720e87c83..eef3a9b54a 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -903,8 +903,8 @@ void MainWindow::drawVideoPanel() { video_h = avail.y - splitter_h - charts_min_h; } } - // The splitter provides the gap; extra ItemSpacing would leave an undraggable strip. - if (!charts_floating_) ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); + // Replay uses a splitter for the gap; live streams use normal item spacing. + if (!charts_floating_ && !live) ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); if (video_h > 0.0f) { ImGui::BeginChild("video", ImVec2(0, video_h), ImGuiChildFlags_Borders); help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); @@ -913,7 +913,7 @@ void MainWindow::drawVideoPanel() { } else { video_widget_->setVisible(false); // the splitter collapsed the video: stop the vipc thread } - if (!charts_floating_) { + if (!charts_floating_ && !live) { ImGui::InvisibleButton("##splitter", ImVec2(-1.0f, splitter_h)); const bool splitter_hovered = ImGui::IsItemHovered() && !live, splitter_active = ImGui::IsItemActive() && !live; if (splitter_active) { @@ -927,6 +927,8 @@ void MainWindow::drawVideoPanel() { ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(splitter.Min.x, line_y), ImVec2(splitter.Max.x, line_y + 2.0f), ImGui::GetColorU32(splitter_active ? ImGuiCol_SeparatorActive : splitter_hovered ? ImGuiCol_SeparatorHovered : ImGuiCol_Border)); ImGui::PopStyleVar(); + } + if (!charts_floating_) { if (!charts_collapsed) { // the chart list scrolls in its own child, the container itself never scrolls ImGui::BeginChild("charts", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index ce8fb64c04..9d80c1a2bd 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -141,7 +141,8 @@ std::string VideoWidget::whatsThis() const { static float toolbarHeight() { return TOOLBAR_MARGIN_Y + ImGui::GetFrameHeight(); } void VideoWidget::drawPlaybackController() { - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + TOOLBAR_MARGIN_Y); + if (!can->liveStreaming()) + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + TOOLBAR_MARGIN_Y); const float speed_width = menuButtonWidth("0.05x", true); const char *play_icon = can->isPaused() ? icon::PLAY : icon::PAUSE; @@ -151,13 +152,15 @@ void VideoWidget::drawPlaybackController() { : formatTime(can->currentSec(), true); const char *time_tooltip = settings.absolute_time ? "Elapsed time" : "Absolute time"; - std::vector items = { - toolbarAction("rewind", icon::REWIND, "Seek backward", []() { can->seekTo(can->currentSec() - 1); }), - toolbarAction("play", play_icon, play_tooltip, []() { can->pause(!can->isPaused()); }, true, true), - toolbarAction("fast-forward", icon::FAST_FORWARD, "Seek forward", []() { can->seekTo(can->currentSec() + 1); }, true, true), - }; + std::vector items; + if (!can->liveStreaming()) { + items.push_back(toolbarAction("rewind", icon::REWIND, "Seek backward", []() { can->seekTo(can->currentSec() - 1); })); + } + items.push_back(toolbarAction("play", play_icon, play_tooltip, []() { can->pause(!can->isPaused()); }, true, true)); if (can->liveStreaming()) { - items.push_back(toolbarAction("skip-end", icon::SKIP_END, "Skip to the end", [this]() { skipToEnd(); }, skip_to_end_enabled_, true)); + items.push_back(toolbarAction("skip-end", icon::SKIP_END, "Go live", [this]() { skipToEnd(); }, skip_to_end_enabled_, true)); + } else { + items.push_back(toolbarAction("fast-forward", icon::FAST_FORWARD, "Seek forward", []() { can->seekTo(can->currentSec() + 1); }, true, true)); } if (slider_ || msgs_received_) { // a mono font: with proportional digits the time changed width as it ticked and the items after it moved @@ -190,13 +193,11 @@ void VideoWidget::drawPlaybackController() { return item; }; const char *aspect_ratio_icon = settings.crop_video ? icon::ASPECT_RATIO_FILL : icon::ASPECT_RATIO; - items.push_back(toolbarAction("crop_video", aspect_ratio_icon, "Crop to fill", [this]() { cropVideoClicked(); })); if (!can->liveStreaming()) { + items.push_back(toolbarAction("crop_video", aspect_ratio_icon, "Crop to fill", [this]() { cropVideoClicked(); })); items.push_back(separator()); items.push_back(toolbarAction("loop", loop_icon, "Loop playback", [this]() { loopPlaybackClicked(); }, true, true)); - } - items.push_back(toolbarMenu("speed_btn", speed_text_, "Speed", [this]() { drawSpeedMenuItems(); }, true, true, speed_width)); - if (!can->liveStreaming()) { + items.push_back(toolbarMenu("speed_btn", speed_text_, "Speed", [this]() { drawSpeedMenuItems(); }, true, true, speed_width)); items.push_back(separator()); items.push_back(toolbarAction("route_info", icon::INFO_CIRCLE, "View route details", [this]() { showRouteInfo(); }, true, true)); } @@ -356,7 +357,7 @@ float VideoWidget::sizeHintHeight() const { // Keep the pane's default proportions stable as frames arrive or cameras change. float VideoWidget::defaultHeight(float width) const { - if (!cam_widget_) return toolbarHeight(); // live streams have no camera or slider + if (!cam_widget_) return ImGui::GetFrameHeight(); // live streams have no camera or slider const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, width / DEFAULT_CAMERA_ASPECT_RATIO); const float tab_height = camera_tab_->count() >= 2 ? ImGui::GetFrameHeight() : 0.0f; return cam_height + tab_height + SLIDER_HEIGHT + toolbarHeight(); From bb86bee6882ed5422c89d2333fe98b85db21812d Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:56:19 -0700 Subject: [PATCH 031/270] cabana: clean up UI wording and formatting (#38803) --- .../tools/cabana/ui/chart/chartswidget.cc | 2 +- .../tools/cabana/ui/chart/signalselector.cc | 4 +-- .../tools/cabana/ui/dialogs/messagebox.cc | 2 +- .../tools/cabana/ui/dialogs/routesdialog.cc | 4 +-- .../tools/cabana/ui/dialogs/streamselector.cc | 16 ++++----- openpilot/tools/cabana/ui/mainwin.cc | 36 +++++++++---------- openpilot/tools/cabana/ui/tools/findsignal.cc | 16 ++++----- .../tools/cabana/ui/tools/findsimilarbits.cc | 6 ++-- .../tools/cabana/ui/widgets/detailwidget.cc | 6 ++-- .../tools/cabana/ui/widgets/messageswidget.cc | 8 ++--- .../tools/cabana/ui/widgets/signalview.cc | 2 +- 11 files changed, 51 insertions(+), 51 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index 16fb7853b1..24ce32aa25 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -69,7 +69,7 @@ std::string ChartsWidget::whatsThis() const { Click: Click to seek to a corresponding time.
Drag: Zoom into the chart.
Shift + Drag: Scrub through the chart to view values.
- Right Mouse: Open the context menu.
+ Right-click: Open the context menu.
)"; } diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc index 2733a65f33..dd84c49cba 100644 --- a/openpilot/tools/cabana/ui/chart/signalselector.cc +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -43,7 +43,7 @@ bool SignalSelector::draw() { ImGui::BeginGroup(); ImGui::TextUnformatted("Available Signals"); // a combo popup with a filter box - const char *preview = msgs_combo_index_ >= 0 ? msgs_combo_[msgs_combo_index_].text.c_str() : "Select a msg..."; + const char *preview = msgs_combo_index_ >= 0 ? msgs_combo_[msgs_combo_index_].text.c_str() : "Select a message..."; ImGui::SetNextItemWidth(column_w); if (ImGui::BeginCombo("##msgs_combo", preview)) { if (ImGui::IsWindowAppearing()) { @@ -51,7 +51,7 @@ bool SignalSelector::draw() { ImGui::SetKeyboardFocusHere(); } ImGui::SetNextItemWidth(-FLT_MIN); - inputText("##msgs_filter", &msgs_combo_filter_, "Select a msg..."); + inputText("##msgs_filter", &msgs_combo_filter_, "Select a message..."); for (int i = 0; i < (int)msgs_combo_.size(); ++i) { if (!msgs_combo_filter_.empty() && !utils::containsCI(msgs_combo_[i].text, msgs_combo_filter_)) continue; if (ImGui::Selectable(msgs_combo_[i].text.c_str(), i == msgs_combo_index_)) { diff --git a/openpilot/tools/cabana/ui/dialogs/messagebox.cc b/openpilot/tools/cabana/ui/dialogs/messagebox.cc index 9d1a2bde97..bac66037a9 100644 --- a/openpilot/tools/cabana/ui/dialogs/messagebox.cc +++ b/openpilot/tools/cabana/ui/dialogs/messagebox.cc @@ -68,7 +68,7 @@ void draw() { ImGui::Separator(); if (!box.detailed_text.empty()) { // the details button sits at the left of the button box - if (ImGui::Button(g_show_details ? "Hide Details..." : "Show Details...")) g_show_details = !g_show_details; + if (ImGui::Button(g_show_details ? "Hide Details" : "Show Details")) g_show_details = !g_show_details; ImGui::SameLine(); } dialogButtons("OK", &result, &done, true, box.has_cancel ? "Cancel" : nullptr); diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc index d0676ae709..8e192dce17 100644 --- a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -61,7 +61,7 @@ void RoutesDialog::setRouteList(const std::vector &list, bool if (success) { for (const auto &route : list) { const int mins = static_cast((route.end_ms - route.start_ms) / 60000); - s_.routes.push_back({routes::formatUnixMs(route.start_ms) + " " + std::to_string(mins) + "min", route.name}); + s_.routes.push_back({routes::formatUnixMs(route.start_ms) + " " + std::to_string(mins) + " min", route.name}); } if (!s_.routes.empty()) s_.route_index = 0; } else { @@ -80,7 +80,7 @@ void RoutesDialog::finish(bool accepted) { void RoutesDialog::draw() { if (!open_) return; - if (!beginDialog("Remote routes", &popup_, ImVec2(480.0f, 420.0f))) return; + if (!beginDialog("Remote Routes", &popup_, ImVec2(480.0f, 420.0f))) return; ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Device"); diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.cc b/openpilot/tools/cabana/ui/dialogs/streamselector.cc index 4c724c6fce..f1d2b1da13 100644 --- a/openpilot/tools/cabana/ui/dialogs/streamselector.cc +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.cc @@ -19,15 +19,15 @@ void OpenReplayWidget::draw() { ImGui::TextUnformatted("Route"); ImGui::SameLine(); ImGui::SetNextItemWidth(-250.0f); - inputText("##route", &route_, "Enter route name or browse for local/remote route"); + inputText("##route", &route_, "Enter a route name or browse for a local or remote route"); ImGui::SameLine(); - if (ImGui::Button("Remote route...")) { + if (ImGui::Button("Remote Route...")) { routes_dialog_.open(utils::guarded(alive_, [this](bool accepted, const std::string &route) { if (accepted) route_ = route; })); } ImGui::SameLine(); - if (ImGui::Button("Local route...")) { + if (ImGui::Button("Local Route...")) { FileDialog::getExistingDirectory("Open Local Route", settings.last_route_dir, utils::guarded(alive_, [this](const std::string &dir) { if (!dir.empty()) { route_ = dir; @@ -128,7 +128,7 @@ void OpenPandaWidget::buildConfigForm() { void OpenPandaWidget::draw() { if (already_connected_) { ImGui::Text("Already connected to %s.", can->routeName().c_str()); - ImGui::TextUnformatted("Close the current connection via [File menu -> Close Stream] before connecting to another Panda."); + ImGui::TextUnformatted("Select File > Close Stream before connecting to another panda."); return; } ImGui::AlignTextToFramePadding(); @@ -193,7 +193,7 @@ void OpenDeviceWidget::draw() { ImGui::SameLine(label_width); ImGui::BeginDisabled(mode_ != 1); ImGui::SetNextItemWidth(-1.0f); - validatedText("##ip", &ip_address_, validateIpAddress, "Enter device Ip Address", ipValidator); + validatedText("##ip", &ip_address_, validateIpAddress, "Enter device IP address", ipValidator); ImGui::EndDisabled(); } @@ -263,7 +263,7 @@ void StreamSelector::open(Callback on_done) { void StreamSelector::draw() { if (!open_) return; - if (!beginDialog("Open stream", &popup_, ImVec2(768.0f, 0.0f))) return; + if (!beginDialog("Open Stream", &popup_, ImVec2(768.0f, 0.0f))) return; AbstractOpenStreamWidget *current = nullptr; const ImVec4 pane = ImGui::GetStyleColorVec4(ImGuiCol_WindowBg); @@ -287,10 +287,10 @@ void StreamSelector::draw() { first_frame_ = false; ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("dbc File"); + ImGui::TextUnformatted("DBC File"); ImGui::SameLine(); ImGui::SetNextItemWidth(-90.0f); - inputText("##dbc", &dbc_file_, "Choose a dbc file to open", ImGuiInputTextFlags_ReadOnly); + inputText("##dbc", &dbc_file_, "Choose a DBC file to open", ImGuiInputTextFlags_ReadOnly); ImGui::SameLine(); if (ImGui::Button("Browse...")) { FileDialog::getOpenFileName("Open File", settings.last_dir, ".dbc", [this](const std::string &fn) { diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index eef3a9b54a..3f8c815e77 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -92,7 +92,7 @@ void MainWindow::loadFingerprints() { void MainWindow::drawFileMenu() { const bool has_stream = hasStream(); if (ImGui::MenuItem("Open Stream...")) selectAndOpenStream(); - if (ImGui::MenuItem("Close stream", nullptr, false, has_stream)) closeStream(); + if (ImGui::MenuItem("Close Stream", nullptr, false, has_stream)) closeStream(); if (ImGui::MenuItem("Export to CSV...", nullptr, false, has_stream)) exportToCSV(); ImGui::Separator(); @@ -115,7 +115,7 @@ void MainWindow::drawFileMenu() { } ImGui::EndMenu(); } - if (ImGui::MenuItem("Load DBC From Clipboard")) loadFromClipboard(); + if (ImGui::MenuItem("Load DBC from Clipboard")) loadFromClipboard(); ImGui::Separator(); const int cnt = dbc()->nonEmptyDBCCount(); @@ -123,7 +123,7 @@ void MainWindow::drawFileMenu() { if (ImGui::MenuItem(save_text.c_str(), shortcut("S").c_str(), false, cnt > 0)) save(); if (ImGui::MenuItem("Save DBC As...", shortcut("Shift+S").c_str(), false, cnt == 1)) saveAs(); // TODO: Support clipboard for multiple files - if (ImGui::MenuItem("Copy DBC To Clipboard", nullptr, false, cnt == 1)) saveToClipboard(); + if (ImGui::MenuItem("Copy DBC to Clipboard", nullptr, false, cnt == 1)) saveToClipboard(); ImGui::Separator(); if (ImGui::MenuItem("Settings...")) openSettings(); @@ -283,7 +283,7 @@ void MainWindow::closeStream() { if (dbc()->nonEmptyDBCCount() > 0) { dbc()->fileChanged(); } - showStatusMessage("stream closed"); + showStatusMessage("Stream closed"); } void MainWindow::exportToCSV() { @@ -315,7 +315,7 @@ void MainWindow::loadFile(const std::string &fn, SourceSet s, std::functionopen(s, fn, &error)) { updateRecentFiles(fn); - showStatusMessage("DBC File " + fn + " loaded", 2000); + showStatusMessage("DBC file " + fn + " loaded", 2000); if (then) then(); } else { MessageBox::warning("Failed to load DBC file", "Failed to parse DBC file " + fn, error, then); @@ -333,11 +333,11 @@ void MainWindow::loadDBCFromOpendbc(const std::string &name) { void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { std::string text; if (!utils::getClipboardText(&text)) { - MessageBox::warning("Load From Clipboard", "No clipboard tool found. Install xclip (X11) or wl-clipboard (Wayland)."); + MessageBox::warning("Load from Clipboard", "No clipboard tool found. Install xclip (X11) or wl-clipboard (Wayland)."); return; } if (text.empty()) { - MessageBox::warning("Load From Clipboard", "Clipboard is empty."); + MessageBox::warning("Load from Clipboard", "Clipboard is empty."); return; } @@ -345,9 +345,9 @@ void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { std::string error; bool ret = dbc()->open(s, std::string(""), text, &error); if (ret && dbc()->nonEmptyDBCCount() > 0) { - MessageBox::information("Load From Clipboard", "DBC Successfully Loaded!"); + MessageBox::information("Load from Clipboard", "DBC loaded successfully."); } else { - MessageBox::warning("Failed to load DBC from clipboard", "Make sure that you paste the text with correct format.", error); + MessageBox::warning("Failed to load DBC from clipboard", "Make sure the clipboard contains correctly formatted DBC text.", error); } }); } @@ -512,9 +512,9 @@ void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { void MainWindow::copyToClipboard(const std::string &text) { if (utils::setClipboardText(text)) { - MessageBox::information("Copy To Clipboard", "DBC Successfully copied!"); + MessageBox::information("Copy to Clipboard", "DBC copied successfully."); } else { - MessageBox::warning("Copy To Clipboard", "Failed to copy DBC to clipboard. Install xclip (X11) or wl-clipboard (Wayland)."); + MessageBox::warning("Copy to Clipboard", "Failed to copy DBC to clipboard. Install xclip (X11) or wl-clipboard (Wayland)."); } } @@ -528,9 +528,9 @@ void MainWindow::drawManageDBCsMenu() { const std::string title = "Bus " + std::to_string(source) + " (" + (dbc_file ? dbc_file->name() : "No DBCs loaded") + ")"; ImGui::PushID(source); if (ImGui::BeginMenu(title.c_str())) { - if (ImGui::MenuItem("New DBC File...")) newFile(ss); + if (ImGui::MenuItem("New DBC File")) newFile(ss); if (ImGui::MenuItem("Open DBC File...")) openFile(ss); - if (ImGui::MenuItem("Load DBC From Clipboard...")) loadFromClipboard(ss, false); + if (ImGui::MenuItem("Load DBC from Clipboard")) loadFromClipboard(ss, false); // Show sub-menu for each dbc for this source. if (dbc_file) { @@ -538,9 +538,9 @@ void MainWindow::drawManageDBCsMenu() { ImGui::MenuItem((dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")").c_str(), nullptr, false, false); if (ImGui::MenuItem("Save...")) saveFile(dbc_file); if (ImGui::MenuItem("Save As...")) saveFileAs(dbc_file); - if (ImGui::MenuItem("Copy to Clipboard...")) saveFileToClipboard(dbc_file); - if (ImGui::MenuItem("Remove from this bus...")) closeFile(ss, {}); - if (ImGui::MenuItem("Remove from all buses...")) closeFile(dbc_file); + if (ImGui::MenuItem("Copy to Clipboard")) saveFileToClipboard(dbc_file); + if (ImGui::MenuItem("Remove from This Bus...")) closeFile(ss, {}); + if (ImGui::MenuItem("Remove from All Buses...")) closeFile(dbc_file); } ImGui::EndMenu(); } @@ -578,7 +578,7 @@ void MainWindow::remindSaveChanges(std::function then) { if (then) then(); return; } - std::string text = "You have unsaved changes. Press ok to save them, cancel to discard."; + std::string text = "You have unsaved changes. Select OK to save them or Cancel to discard them."; MessageBox::question("Unsaved Changes", text, [this, then](bool ok) { if (ok) { save([this, then]() { remindSaveChanges(then); }); @@ -755,7 +755,7 @@ void MainWindow::drawStatusBar() { ImGui::TextUnformatted(bar.message.c_str()); } else { bar.message.clear(); - ImGui::TextUnformatted("For Help, Press F1"); + ImGui::TextUnformatted("For help, press F1"); } if (bar.progress_visible) { ImGui::SameLine(width - pad - 300.0f); diff --git a/openpilot/tools/cabana/ui/tools/findsignal.cc b/openpilot/tools/cabana/ui/tools/findsignal.cc index 1642ac2cc5..c3ce025f4c 100644 --- a/openpilot/tools/cabana/ui/tools/findsignal.cc +++ b/openpilot/tools/cabana/ui/tools/findsignal.cc @@ -89,7 +89,7 @@ bool FindSignalDlg::draw() { drawFindGroup(); ImGui::EndChild(); if (searched_) { - ImGui::Text("%zu matches. right click on an item to create signal. double click to open message", + ImGui::Text("%zu matches. Right-click an item to create a signal. Double-click to open the message.", search_.filtered_signals.size()); } } @@ -103,12 +103,12 @@ void FindSignalDlg::drawMessageGroup() { ImGui::TextUnformatted("Bus"); ImGui::SameLine(80); ImGui::SetNextItemWidth(-1); - inputText("##bus", &bus_, "comma-separated values. Leave blank for all"); + inputText("##bus", &bus_, "Comma-separated values. Leave blank for all."); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Address"); ImGui::SameLine(80); ImGui::SetNextItemWidth(-1); - inputText("##address", &address_, "comma-separated hex values. Leave blank for all"); + inputText("##address", &address_, "Comma-separated hex values. Leave blank for all."); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Time"); ImGui::SameLine(80); @@ -138,7 +138,7 @@ void FindSignalDlg::drawPropertiesGroup() { ImGui::SetNextItemWidth(70); if (ImGui::InputInt("##max_size", &max_size_, 1, 10)) max_size_ = std::clamp(max_size_, 1, 64); ImGui::SameLine(); - checkBox("Little endian", &little_endian_); + checkBox("Little Endian", &little_endian_); ImGui::SameLine(); checkBox("Signed", &is_signed_); ImGui::AlignTextToFramePadding(); @@ -157,7 +157,7 @@ void FindSignalDlg::drawPropertiesGroup() { void FindSignalDlg::drawFindGroup() { static const char *compare_items[] = {"=", ">", ">=", "!=", "<", "<=", "between"}; const int compare_count = IM_ARRAYSIZE(compare_items); - ImGui::TextUnformatted("Find signal"); + ImGui::TextUnformatted("Find Signal"); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Value"); ImGui::SameLine(); @@ -177,14 +177,14 @@ void FindSignalDlg::drawFindGroup() { ImGui::SameLine(); const bool first = !searching_ && search_.histories.empty(); ImGui::BeginDisabled(searching_ || search_.histories.size() <= 1); - if (ImGui::Button("Undo prev find")) { + if (ImGui::Button("Undo Previous Find")) { search_.undo(); searched_ = true; } ImGui::EndDisabled(); ImGui::SameLine(); ImGui::BeginDisabled(searching_ || (search_.filtered_signals.empty() && !first)); - if (ImGui::Button(searching_ ? "Finding ...." : (first ? "Find" : "Find Next"))) search(); + if (ImGui::Button(searching_ ? "Finding..." : (first ? "Find" : "Find Next"))) search(); ImGui::EndDisabled(); ImGui::SameLine(); ImGui::BeginDisabled(searching_ || first); @@ -203,7 +203,7 @@ void FindSignalDlg::drawFindGroup() { } void FindSignalDlg::drawTable() { - static const char *titles[] = {"Id", "Start Bit, size", "(time, value)"}; + static const char *titles[] = {"ID", "Start Bit, Size", "(Time, Value)"}; const int columns = IM_ARRAYSIZE(titles); const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_NoSavedSettings; if (!ImGui::BeginTable("view", columns + 1, flags, ImVec2(0, 0))) return; diff --git a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc index 81ae3660a9..daffc5408c 100644 --- a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc +++ b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc @@ -9,7 +9,7 @@ #include "tools/cabana/ui/util.h" FindSimilarBitsDlg::FindSimilarBitsDlg() { - setTitle("Find similar bits"); + setTitle("Find Similar Bits"); for (int bus : can->sources) { bus_items_.push_back(bus); @@ -64,7 +64,7 @@ bool FindSimilarBitsDlg::draw() { ImGui::SetNextItemWidth(60); ImGui::Combo("##equal", &equal_, "Yes\0No\0"); ImGui::SameLine(); - ImGui::TextUnformatted("Min msg count"); + ImGui::TextUnformatted("Minimum Message Count"); ImGui::SameLine(); ImGui::SetNextItemWidth(80); if (ImGui::InputInt("##min_msgs", &min_msgs_, 1, 10)) min_msgs_ = std::max(min_msgs_, 0); @@ -86,7 +86,7 @@ void FindSimilarBitsDlg::drawTable() { const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_NoSavedSettings; if (!ImGui::BeginTable("table", 7, flags, ImVec2(0, 0))) return; ImGui::TableSetupScrollFreeze(0, 1); - static const char *headers[] = {"address", "byte idx", "bit idx", "mismatches", "total msgs", "% mismatched"}; + static const char *headers[] = {"Address", "Byte Index", "Bit Index", "Mismatches", "Messages", "Mismatched (%)"}; // the fixed widths are section sizes: imgui adds the cell padding on top of the column width const float padding = ImGui::GetStyle().CellPadding.x * 2; ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 40.0f - padding); // vertical header: row number diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 00ed44b09d..0e5cda1bd2 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -310,7 +310,7 @@ std::vector> DetailWidget::helpRects() const { EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const std::string &title, int size, float parent_width) : msg_id_(msg_id), original_name_(title), name_edit_(title), size_spin_(size), width_(parent_width * 0.9f) { - window_title_ = "Edit message: " + msg_id.toString(); + window_title_ = "Edit Message: " + msg_id.toString(); if (auto msg = dbc()->msg(msg_id)) { node_ = msg->transmitter; @@ -438,10 +438,10 @@ void CenterWidget::drawWelcomeWidget() { y += ImGui::GetFrameHeightWithSpacing(); }; - centered("<-Select a message to view details", y); + centered("<- Select a message to view details", y); y += ImGui::GetTextLineHeightWithSpacing(); newShortcutRow("Pause", "Space"); newShortcutRow("Help", "F1"); - newShortcutRow("WhatsThis", "Shift+F1"); + newShortcutRow("What's This?", "Shift+F1"); ImGui::PopStyleColor(); } diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc index 08148107a0..52e0687204 100644 --- a/openpilot/tools/cabana/ui/widgets/messageswidget.cc +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -231,11 +231,11 @@ std::string MessagesWidget::whatsThis() const { return R"( Message View
Byte color
- ■ constant changing
+ ■ constantly changing
■ increasing
■ decreasing
Shortcuts
- Horizontal Scrolling:  shift+wheel  + Horizontal Scrolling:  Shift+Wheel  )"; } @@ -310,10 +310,10 @@ void MessagesWidget::drawContextMenu() { } } ImGui::Separator(); - if (ImGui::MenuItem("Multi-Line bytes", nullptr, settings.multiple_lines_hex)) { + if (ImGui::MenuItem("Multiline Bytes", nullptr, settings.multiple_lines_hex)) { setMultiLineBytes(!settings.multiple_lines_hex); } - if (ImGui::MenuItem("Show inactive messages", nullptr, list_.show_inactive_messages)) { + if (ImGui::MenuItem("Show Inactive Messages", nullptr, list_.show_inactive_messages)) { list_.showInactiveMessages(!list_.show_inactive_messages); } ImGui::EndPopup(); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index 2413912ea2..d98058354a 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -883,7 +883,7 @@ void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { showChart(model_.msgId(), sig, item->chart_opened, ImGui::GetIO().KeyShift); } if (checked) ImGui::PopStyleColor(); - ImGui::SetItemTooltip("%s", checked ? "Close Plot" : "Show Plot\nSHIFT click to add to previous opened plot"); + ImGui::SetItemTooltip("%s", checked ? "Close Plot" : "Show Plot\nShift-click to add to the previously opened plot"); ImGui::SameLine(0.0f, spacing); if (iconButton("remove", icon::X_LG) && !editor_open_on_press_) { pending_action_ = [this, sig]() { UndoStack::instance()->push(new RemoveSigCommand(model_.msgId(), sig)); }; From 10b9e73859cb5838548305604fb1f34446becc34 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:06:01 -0700 Subject: [PATCH 032/270] cabana: fill event bar to camera edges (#38807) --- openpilot/tools/cabana/ui/widgets/videowidget.cc | 10 +++++----- openpilot/tools/cabana/ui/widgets/videowidget.h | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 9d80c1a2bd..3149619d71 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -523,7 +523,7 @@ void StreamCameraView::draw(const ImVec2 &size, double thumbnail_time) { scrubbing ? drawScrubThumbnail(p, thumbnail_time) : drawThumbnail(p, thumbnail_time); } if (auto alert = getReplay()->findAlertAtTime(scrubbing ? thumbnail_time : can->currentSec())) { - drawAlert(p, rect(), *alert, ImGui::GetFontSize()); + drawAlert(p, rect(), *alert, ImGui::GetFontSize(), ImGui::GetStyle().ChildRounding); } if (can->isPaused()) { @@ -571,7 +571,7 @@ void StreamCameraView::drawThumbnail(ImDrawList *p, double sec) { p->AddRect(thumb_rect.Min, thumb_rect.Max, IM_COL32_WHITE, ImGui::GetStyle().FrameRounding, 0, 2.0f); // look up the alert at the hovered time, the thumbnail frame itself can be seconds away if (auto alert = getReplay()->findAlertAtTime(sec)) { - drawAlert(p, thumb_rect, *alert, POINT_10_FONT_SIZE); + drawAlert(p, thumb_rect, *alert, POINT_10_FONT_SIZE, ImGui::GetStyle().FrameRounding); } drawTime(p, thumb_rect, sec); } @@ -587,17 +587,17 @@ void StreamCameraView::drawTime(ImDrawList *p, const ImRect &rect, double second IM_COL32_WHITE, text); } -void StreamCameraView::drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size) { +void StreamCameraView::drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size, float rounding) { const ImU32 pen = IM_COL32_WHITE; ImU32 color = withAlpha(timeline_colors[int(alert.type)], 128); std::string text = alert.text1; if (!alert.text2.empty()) text += "\n" + alert.text2; - ImRect text_rect(ImVec2(rect.Min.x + 1, rect.Min.y + 1), ImVec2(rect.Max.x - 1, rect.Max.y - 1)); + const ImRect &text_rect = rect; ImFont *font = ImGui::GetFont(); const float wrap_width = std::max(1.0f, text_rect.GetWidth()); const ImVec2 r = font->CalcTextSizeA(font_size, FLT_MAX, wrap_width, text.c_str()); - p->AddRectFilled(ImVec2(text_rect.Min.x, text_rect.Min.y), ImVec2(text_rect.Max.x, text_rect.Min.y + r.y), color, ImGui::GetStyle().FrameRounding, ImDrawFlags_RoundCornersTop); + p->AddRectFilled(ImVec2(text_rect.Min.x, text_rect.Min.y), ImVec2(text_rect.Max.x, text_rect.Min.y + r.y), color, rounding, ImDrawFlags_RoundCornersTop); // each line is centered, wrapped continuations stay left aligned float y = text_rect.Min.y; for (const auto &line : utils::split(text, '\n')) { diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h index f7f0ced062..1abdbdbe71 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.h +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -70,7 +70,7 @@ private: void collectThumbnails(); // moves the decoded thumbnails in once a parseQLog task is done // the first thumbnail at or after sec, uploaded to big_thumbnail_texture_; nullptr when there is none const RgbImage *thumbnailAt(double sec); - void drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size); + void drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size, float rounding); void drawThumbnail(ImDrawList *p, double sec); void drawScrubThumbnail(ImDrawList *p, double sec); void drawTime(ImDrawList *p, const ImRect &rect, double seconds); From 3eafb658bf3e12fa2e515958aa641fd5a99fdf67 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:48:33 -0700 Subject: [PATCH 033/270] mici: remove duplicate pairing button from main settings (#38809) --- openpilot/selfdrive/ui/mici/layouts/settings/settings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py index eb7789cba9..477bd78935 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py @@ -3,7 +3,7 @@ from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.network.network_layout import NetworkLayoutMici -from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton +from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.software import SoftwareLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout @@ -49,7 +49,6 @@ class SettingsLayout(NavScroller): network_btn, device_btn, software_btn, - PairBigButton(), firehose_btn, developer_btn, ]) From 8f2d66d0e5d35daf93670269e336047627eaa745 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:26:53 -0700 Subject: [PATCH 034/270] cabana: round only the outer video frame (#38808) * cabana: remove video frame corner rounding * cabana: round the outer video frame including letterboxing --- .../tools/cabana/ui/widgets/cameraview.cc | 19 ++++++++++++++++++- .../tools/cabana/ui/widgets/cameraview.h | 2 ++ .../tools/cabana/ui/widgets/videowidget.cc | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.cc b/openpilot/tools/cabana/ui/widgets/cameraview.cc index 460ef47c25..b771717c22 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.cc +++ b/openpilot/tools/cabana/ui/widgets/cameraview.cc @@ -21,6 +21,23 @@ void generateMipmap() { } } // namespace +void drawVideoFrame(ImDrawList *draw_list, ImTextureRef texture, const ImRect &rect, const VideoPlacement &placement) { + const ImVec2 size(placement.max.x - placement.min.x, placement.max.y - placement.min.y); + if (size.x <= 0 || size.y <= 0) return; + + // Round the full frame, then clip to the square video bounds so letterboxing + // doesn't introduce a second set of rounded corners around the image. + const ImVec2 uv_scale((placement.uv1.x - placement.uv0.x) / size.x, + (placement.uv1.y - placement.uv0.y) / size.y); + const ImVec2 uv0(placement.uv0.x + (rect.Min.x - placement.min.x) * uv_scale.x, + placement.uv0.y + (rect.Min.y - placement.min.y) * uv_scale.y); + const ImVec2 uv1(placement.uv1.x + (rect.Max.x - placement.max.x) * uv_scale.x, + placement.uv1.y + (rect.Max.y - placement.max.y) * uv_scale.y); + draw_list->PushClipRect(placement.min, placement.max, true); + draw_list->AddImageRounded(texture, rect.Min, rect.Max, uv0, uv1, IM_COL32_WHITE, ImGui::GetStyle().ChildRounding); + draw_list->PopClipRect(); +} + void GlTexture::upload(const RgbImage &image) { if (id == 0) { glGenTextures(1, &id); @@ -114,7 +131,7 @@ void CameraWidget::paint() { // mirror cabin camera horizontally std::swap(placement.uv0.x, placement.uv1.x); } - p->AddImageRounded(frame_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1, IM_COL32_WHITE, ImGui::GetStyle().ChildRounding); + drawVideoFrame(p, frame_texture_.ref(), rect_, placement); } void CameraWidget::vipcThread() { diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.h b/openpilot/tools/cabana/ui/widgets/cameraview.h index 000e745f66..db4e04f63c 100644 --- a/openpilot/tools/cabana/ui/widgets/cameraview.h +++ b/openpilot/tools/cabana/ui/widgets/cameraview.h @@ -50,6 +50,8 @@ inline VideoPlacement videoPlacement(const ImRect &rect, float source_aspect_rat return placement; } +void drawVideoFrame(ImDrawList *draw_list, ImTextureRef texture, const ImRect &rect, const VideoPlacement &placement); + // tightly packed RGBA pixels struct RgbImage { int width = 0; diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 3149619d71..5122ca96fa 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -550,7 +550,7 @@ void StreamCameraView::drawScrubThumbnail(ImDrawList *p, double sec) { p->AddRectFilled(rect().Min, rect().Max, IM_COL32(0, 0, 0, 255), ImGui::GetStyle().ChildRounding); if (const RgbImage *image = thumbnailAt(sec)) { const VideoPlacement placement = videoPlacement(rect(), (float)image->width / image->height, settings.crop_video); - p->AddImageRounded(big_thumbnail_texture_.ref(), placement.min, placement.max, placement.uv0, placement.uv1, IM_COL32_WHITE, ImGui::GetStyle().ChildRounding); + drawVideoFrame(p, big_thumbnail_texture_.ref(), rect(), placement); drawTime(p, rect(), sec); } } From 25d9d41c9007e64de7bb28ff8f27c7148a34b5ab Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:55:28 -0700 Subject: [PATCH 035/270] esim: comma four profile management UI (#37844) * esim: MICI eSIM profile management UI * esim: align rename button position regardless of delete button * esim: skip profile UI when SIM is not an eUICC Probe is_euicc() on the first profile poll and cache it; non-eUICC SIMs avoid list_profiles/process_notifications (which hang on a plain SIM) and the eSIM button shows ICCID/MCC-MNC metadata instead of opening the profile management screen. * esim: satisfy ruff E731 in action_pressed helper * esim: get modem info from modem.py state instead of shelling out * esim: simplify switch lifecycle, drop active flag and settle window * esim: only show 'switching...' on the target profile * esim: read cell strength directly from HARDWARE * esim: hide checkmark and dim cell icon during switch; keep rename available * esim: disable active profile button (rename still clickable as overlay) * esim: stop rename/delete buttons and labels from flashing during operations * esim: show 'comma prime' on network button for comma profile * esim: move display_name and is_comma onto Profile dataclass * esim: anchor rename button to rightmost slot to prevent shift * esim: include iccid prefix check in Profile.is_comma * esim: drop process_notifications from cellular manager * esim: disable network button when SIM isn't an eUICC * esim: rename ESim* classes to Esim* * esim: sort imports * esim: default to 'loading...' instead of 'no active profile' * esim: rename PROFILE_POLL_INTERVAL to PROFILE_POLL_INTERVAL_S * esim: slim EsimNetworkButton * esim: use DEFAULT_TEXT_COLOR; load delete dialog texture in __init__ * esim: revert cell-icon index trick, name each NetworkStrength explicitly * esim: tighten delete/rename spacing so 'switch' label fits on one line * esim: shrink delete/rename buttons by 25% * esim: keep rename button at full size, only delete shrinks * Revert "disable modem.py for now" This reverts commit 1eeba86ec1826add74837dcf40beaf8ce81fb865. * Revert "modem.py is disabled" This reverts commit d238a1ccc4d72a9165d9483ea8bd84d218ad8f00. * lpa: inline comma iccid prefix * ui/cellular_manager: clear switching state when LPA returns * ui/cellular_manager: lock callback queue, drop poll log noise * esim: sort NetworkStrength/NetworkType imports * esim: drop switching_iccid concept * esim: show 'switching...' on the clicked profile button * esim: optimistic switch, skip post-switch list_profiles to avoid flicker * esim: only style profile button from local op flags, not global busy * esim: remove deleting/switching state, collapse profile button branches * esim: show GSM settings on full prime when non-comma profile is active * esim: expose CellularManager.active_profile, dedup callers * esim: apply comma prime defaults on switch (roaming, metered, no APN) * esim: restore re_sort on show_event to avoid first-frame jank * lpa: apply comma prime defaults inside TiciLPA.switch_profile * lpa: lazy-import Params to break circular import * lpa: treat +CME ERROR 13 (SIM failure) as non-eUICC in is_euicc * esim: show 'obtaining IP...' on non-eUICC path while connecting * Revert "lpa: treat +CME ERROR 13 (SIM failure) as non-eUICC in is_euicc" This reverts commit 475ca448ea914ff8f9892b4cbf4525d961d7618b. * esim: poll profiles every 5s * lpa: tighten Profile.is_comma to require both Webbing provider and comma BIN * lpa: fall back to '' instead of iccid prefix in display_name * esim: re-probe is_euicc each poll for runtime SIM swaps * esim_ui: rename EsimUIMici to EsimUI * esim: simplify cellular manager and profile UI - optimistic profile switch at click time so the active button no longer bounces back - unify LPA worker threads, refresh_profiles resets the poll timer - deterministic profile ordering on show - reuse wifi ForgetButton for delete, drop DeleteButton - construct CellularManager inside NetworkLayoutMici - drop comma prime param defaults from LPA.switch_profile (moved to a separate PR) * esim: drop eUICC state change log * ui: gate cellular settings on prime subscription * ui: disable eSIM management for full prime * ui: keep eSIM management disabled for full prime * ui: unify eSIM profile action buttons * ui: tighten eSIM profile action spacing * ui: place rename before delete in eSIM actions * esim: restrict individual profiles and process switch notifications * esim: defer poll after operations and confirm eUICC loss before clearing profiles * ui: allow cellular settings for unregistered and unpaired devices * ui: disable all profile switching for full prime * ui: simplify cellular access to not full prime * ui: make profile poll interval a CellularManager constant * ui: read modem state on the profile poll cadence * ui: scope eSIM profile colors to their class * ui: inline modem state read in cellular polling * ui: rely on hardware modem state fallback --- openpilot/common/esim/base.py | 7 + openpilot/common/hardware/base.py | 3 + openpilot/selfdrive/ui/lib/prime_state.py | 4 + .../mici/layouts/settings/network/__init__.py | 47 ++++ .../mici/layouts/settings/network/esim_ui.py | 230 ++++++++++++++++++ .../settings/network/network_layout.py | 24 +- openpilot/system/ui/lib/cellular_manager.py | 151 ++++++++++++ 7 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py create mode 100644 openpilot/system/ui/lib/cellular_manager.py diff --git a/openpilot/common/esim/base.py b/openpilot/common/esim/base.py index b783a630b2..472b523e2c 100644 --- a/openpilot/common/esim/base.py +++ b/openpilot/common/esim/base.py @@ -21,6 +21,13 @@ class Profile: def is_comma(self) -> bool: return self.provider == 'Webbing' and self.iccid.startswith('8985235') + @property + def display_name(self) -> str: + if self.is_comma: + return "comma prime" + name = self.nickname or self.provider or "" + return f"{name} (...{self.iccid[-4:]})" + class LPABase(ABC): @abstractmethod diff --git a/openpilot/common/hardware/base.py b/openpilot/common/hardware/base.py index 5d8f7770dc..7c8842a0ec 100644 --- a/openpilot/common/hardware/base.py +++ b/openpilot/common/hardware/base.py @@ -145,6 +145,9 @@ class HardwareBase(ABC): def get_modem_temperatures(self): return [] + def get_modem_state(self) -> dict: + return {} + def initialize_hardware(self): pass diff --git a/openpilot/selfdrive/ui/lib/prime_state.py b/openpilot/selfdrive/ui/lib/prime_state.py index b57380ac57..1fa87e31df 100644 --- a/openpilot/selfdrive/ui/lib/prime_state.py +++ b/openpilot/selfdrive/ui/lib/prime_state.py @@ -101,6 +101,10 @@ class PrimeState: with self._lock: return bool(self.prime_type > PrimeType.NONE) + def is_full_prime(self) -> bool: + with self._lock: + return self.prime_type > PrimeType.NONE and self.prime_type != PrimeType.LITE + def is_paired(self) -> bool: with self._lock: return self.prime_type > PrimeType.UNPAIRED diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py index ddbab4b478..4719feecb9 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -1,10 +1,57 @@ import pyray as rl +from openpilot.cereal import log from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiIcon from openpilot.selfdrive.ui.mici.widgets.button import BigButton +from openpilot.common.hardware import HARDWARE from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid +NetworkStrength = log.DeviceState.NetworkStrength +NetworkType = log.DeviceState.NetworkType + + +class EsimNetworkButton(BigButton): + def __init__(self, cellular_manager: CellularManager): + self._cellular_manager = cellular_manager + self._cell_icons = { + NetworkStrength.unknown: gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 64, 47), + NetworkStrength.poor: gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 64, 47), + NetworkStrength.moderate: gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 64, 47), + NetworkStrength.good: gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 64, 47), + NetworkStrength.great: gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 64, 47), + } + super().__init__("esim", "loading...", self._cell_icons[NetworkStrength.unknown], scroll=True) + + def _update_state(self): + super()._update_state() + self.set_enabled(self._cellular_manager.is_euicc is not False) + text, value, icon = self._compute_state() + self.set_text(text) + self.set_value(value) + self.set_icon(icon) + + def _compute_state(self): + cm = self._cellular_manager + none_icon = self._cell_icons[NetworkStrength.unknown] + ip = cm.modem_state.get("ip_address") or "obtaining IP..." + if cm.is_euicc is False: + iccid = cm.modem_state.get("iccid") or "" + if not iccid: + return "sim", "no sim", none_icon + return f"sim (...{iccid[-4:]})", ip, self._cell_icon() + + active = cm.active_profile + if active is None: + return "esim", "loading...", none_icon + return active.display_name, ip, self._cell_icon() + + def _cell_icon(self): + # read directly from HARDWARE so it reflects modem state even when wifi is the active connection + strength = HARDWARE.get_network_strength(NetworkType.cell4G) + return self._cell_icons.get(strength, self._cell_icons[NetworkStrength.unknown]) + class WifiNetworkButton(BigButton): def __init__(self, wifi_manager: WifiManager): diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py new file mode 100644 index 0000000000..0e115b232f --- /dev/null +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -0,0 +1,230 @@ +import pyray as rl +from collections.abc import Callable + +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog, BigConfirmationDialog +from openpilot.common.esim.base import Profile +from openpilot.system.ui.lib.application import DEFAULT_TEXT_COLOR, FontWeight, MousePos, TextAlignment, gui_app +from openpilot.system.ui.lib.cellular_manager import CellularManager +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import gui_label +from openpilot.system.ui.widgets.scroller import NavScroller + + +class ProfileActionButton(Widget): + SIZE = 68 + MARGIN = 10 + HORIZONTAL_MARGIN = 4 + + def __init__(self, callback: Callable, delete: bool = False): + super().__init__() + self.set_click_callback(callback) + self._delete = delete + self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 25, 30) if delete else None + + self._bg_txt = gui_app.texture("icons_mici/buttons/button_circle.png", self.SIZE, self.SIZE) + self._bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_pressed.png", self.SIZE, self.SIZE) + self.set_rect(rl.Rectangle(0, 0, self.SIZE + self.HORIZONTAL_MARGIN * 2, self.SIZE + self.MARGIN * 2)) + + def _render(self, _): + bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt + rl.draw_texture_ex(bg_txt, (self._rect.x + (self._rect.width - self._bg_txt.width) / 2, + self._rect.y + (self._rect.height - self._bg_txt.height) / 2), 0, 1.0, rl.WHITE) + color = rl.Color(255, 105, 115, 255) if self._delete else DEFAULT_TEXT_COLOR + if not self.enabled: + color = rl.Color(color.r, color.g, color.b, 90) + if self._trash_txt: + rl.draw_texture_ex(self._trash_txt, (self._rect.x + (self._rect.width - self._trash_txt.width) / 2, + self._rect.y + (self._rect.height - self._trash_txt.height) / 2), 0, 1.0, color) + else: + gui_label(self._rect, "Aa", 30, color=color, alignment=TextAlignment.CENTER) + + +class EsimProfileButton(BigButton): + SUB_LABEL_DISABLED = rl.Color(255, 255, 255, int(255 * 0.585)) + CHECK_ICON_COLOR = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) + LABEL_PADDING = 98 + LABEL_WIDTH = 402 - 98 - 28 + SUB_LABEL_WIDTH = 402 - BigButton.LABEL_HORIZONTAL_PADDING * 2 + + def __init__(self, profile: Profile, cellular_manager: CellularManager, profiles_enabled: Callable[[], bool]): + self._cellular_manager = cellular_manager + self._profiles_enabled = profiles_enabled + super().__init__(profile.display_name, scroll=True) + + self._profile = profile + + self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 48, 36) + self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 48, 36) + self._check_txt = gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 32, 32) + self._comma_txt = gui_app.texture("icons_mici/settings/comma_icon.png", 36, 36) if profile.is_comma else None + + self._delete_btn = ProfileActionButton(self._on_delete, delete=True) + self._rename_btn = ProfileActionButton(self._on_rename) if not profile.is_comma else None + self._delete_btn.set_enabled(lambda: not self._locked and not self._cellular_manager.busy and self._show_delete_btn) + if self._rename_btn: + self._rename_btn.set_enabled(lambda: not self._locked and not self._cellular_manager.busy) + self.set_enabled(lambda: not self._profile.enabled and self._profiles_enabled() and not self._cellular_manager.busy) + self.update_profile(profile) + + @property + def profile(self) -> Profile: + return self._profile + + def update_profile(self, profile: Profile): + self._profile = profile + active = profile.enabled + self.set_text(profile.display_name) + self.set_value("active" if active else "switch") + + def _update_state(self): + super()._update_state() + self._sub_label.set_color(DEFAULT_TEXT_COLOR if self.enabled else self.SUB_LABEL_DISABLED) + self._sub_label.set_font_weight(FontWeight.SEMI_BOLD if self.enabled else FontWeight.ROMAN) + + @property + def _locked(self) -> bool: + return not self._profile.is_comma and not self._profiles_enabled() + + @property + def _show_delete_btn(self) -> bool: + return not self._profile.enabled and not self._profile.is_comma + + def _on_rename(self): + current = self._profile.nickname or "" + dlg = BigInputDialog("nickname", default_text=current, minimum_length=0, confirm_callback=self._on_nickname_entered) + gui_app.push_widget(dlg) + + def _on_delete(self): + icon = gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64) + gui_app.push_widget(BigConfirmationDialog("slide to delete", icon, self._delete_profile, red=True)) + + def _delete_profile(self): + if not self._locked and not self._cellular_manager.busy and self._show_delete_btn: + self._cellular_manager.delete_profile(self._profile.iccid) + + def _on_nickname_entered(self, nickname: str): + if not self._locked and not self._cellular_manager.busy: + self._cellular_manager.nickname_profile(self._profile.iccid, nickname.strip()) + + def _handle_mouse_release(self, mouse_pos: MousePos): + if self._show_delete_btn and rl.check_collision_point_rec(mouse_pos, self._delete_btn.rect): + return + if self._rename_btn is not None and rl.check_collision_point_rec(mouse_pos, self._rename_btn.rect): + return + super()._handle_mouse_release(mouse_pos) + + def _get_label_font_size(self): + return 48 + + def _draw_content(self, btn_y: float): + self._label.set_color(self.SUB_LABEL_DISABLED if self._locked else LABEL_COLOR) + label_rect = rl.Rectangle(self._rect.x + self.LABEL_PADDING, btn_y + self.LABEL_VERTICAL_PADDING, + self.LABEL_WIDTH, self._rect.height - self.LABEL_VERTICAL_PADDING * 2) + self._label.render(label_rect) + + active = self._profile.enabled + + if self.value: + sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING + label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING + action_w = self._rename_btn.rect.width if self._rename_btn is not None else 0 + action_w += self._delete_btn.rect.width if self._show_delete_btn else 0 + sub_label_w = self.SUB_LABEL_WIDTH - action_w + sub_label_height = self._sub_label.get_content_height(sub_label_w) + + if active: + check_y = int(label_y - sub_label_height + (sub_label_height - self._check_txt.height) / 2) + rl.draw_texture_ex(self._check_txt, rl.Vector2(sub_label_x, check_y), 0.0, 1.0, self.CHECK_ICON_COLOR) + sub_label_x += self._check_txt.width + 14 + + sub_label_rect = rl.Rectangle(sub_label_x, label_y - sub_label_height, sub_label_w, sub_label_height) + self._sub_label.render(sub_label_rect) + + if self._comma_txt: + rl.draw_texture_ex(self._comma_txt, (self._rect.x + 36, btn_y + 38), 0.0, 1.0, rl.WHITE) + else: + cell_icon = self._cell_full_txt if active else self._cell_none_txt + rl.draw_texture_ex(cell_icon, (self._rect.x + 30, btn_y + 38), 0.0, 1.0, rl.WHITE) + + btn_x = self._rect.x + self._rect.width - (ProfileActionButton.MARGIN - ProfileActionButton.HORIZONTAL_MARGIN) + btn_bottom = btn_y + self._rect.height + if self._show_delete_btn: + btn_x -= self._delete_btn.rect.width + self._delete_btn.render(rl.Rectangle( + btn_x, btn_bottom - self._delete_btn.rect.height, + self._delete_btn.rect.width, self._delete_btn.rect.height, + )) + if self._rename_btn is not None: + btn_x -= self._rename_btn.rect.width + self._rename_btn.render(rl.Rectangle( + btn_x, btn_bottom - self._rename_btn.rect.height, + self._rename_btn.rect.width, self._rename_btn.rect.height, + )) + + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + def action_pressed() -> bool: + return self._delete_btn.is_pressed or (self._rename_btn is not None and self._rename_btn.is_pressed) + super().set_touch_valid_callback(lambda: touch_callback() and not action_pressed()) + self._delete_btn.set_touch_valid_callback(touch_callback) + if self._rename_btn: + self._rename_btn.set_touch_valid_callback(touch_callback) + + +class EsimUI(NavScroller): + def __init__(self, cellular_manager: CellularManager, profiles_enabled: Callable[[], bool]): + super().__init__() + + self._cellular_manager = cellular_manager + self._profiles_enabled = profiles_enabled + + self._cellular_manager.on_profiles_updated = self._update_buttons + self._cellular_manager.on_operation_error = self._on_error + + def show_event(self): + super().show_event() + self._update_buttons(re_sort=True) + self._cellular_manager.refresh_profiles() + + def _update_buttons(self, re_sort: bool = False): + existing = {btn.profile.iccid: btn for btn in self._scroller.items} + buttons = [] + for profile in self._cellular_manager.profiles: + btn = existing.get(profile.iccid) + if btn is None: + btn = EsimProfileButton(profile, self._cellular_manager, self._profiles_enabled) + btn.set_click_callback(lambda btn=btn: self._on_profile_clicked(btn.profile)) + self._scroller.add_widget(btn) + else: + btn.update_profile(profile) + buttons.append(btn) + + if re_sort: + self._scroller.items[:] = sorted(buttons, key=lambda b: not b.profile.enabled) + else: + self._scroller.items[:] = [btn for btn in self._scroller.items if btn in buttons] + + def _move_profile_to_front(self, iccid: str | None, scroll: bool = False): + front_btn_idx = next((i for i, btn in enumerate(self._scroller.items) if btn.profile.iccid == iccid), None) if iccid else None + + if front_btn_idx is not None and front_btn_idx > 0: + self._scroller.move_item(front_btn_idx, 0) + + if scroll: + self._scroller.scroll_to(self._scroller.scroll_panel.get_offset(), smooth=True) + + def _update_state(self): + super()._update_state() + + active = self._cellular_manager.active_profile + self._move_profile_to_front(active.iccid if active else None) + + def _on_error(self, error: str): + dlg = BigDialog("esim error", error) + gui_app.push_widget(dlg) + + def _on_profile_clicked(self, profile: Profile): + if self._cellular_manager.busy or not self._profiles_enabled(): + return + self._cellular_manager.switch_profile(profile.iccid) + self._move_profile_to_front(profile.iccid, scroll=True) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py index 58b3d0c77d..d991ed5806 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -1,12 +1,13 @@ -from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiNetworkButton +from openpilot.selfdrive.ui.mici.layouts.settings.network import EsimNetworkButton, WifiNetworkButton +from openpilot.selfdrive.ui.mici.layouts.settings.network.esim_ui import EsimUI from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigParamControl, BigToggle from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.selfdrive.ui.lib.prime_state import PrimeType from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType +from openpilot.system.ui.widgets.scroller import NavScroller class NetworkLayoutMici(NavScroller): @@ -64,6 +65,15 @@ class NetworkLayoutMici(NavScroller): self._wifi_button = WifiNetworkButton(self._wifi_manager) self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui)) + # ******** eSIM ******** + self._cellular_manager = CellularManager() + self._esim_ui = EsimUI( + self._cellular_manager, + lambda: not ui_state.prime_state.is_full_prime(), + ) + self._esim_button = EsimNetworkButton(self._cellular_manager) + self._esim_button.set_click_callback(lambda: gui_app.push_widget(self._esim_ui)) + # ******** Advanced settings ******** # ******** Roaming toggle ******** self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming") @@ -78,6 +88,7 @@ class NetworkLayoutMici(NavScroller): # Main scroller ---------------------------------- self._scroller.add_widgets([ self._wifi_button, + self._esim_button, self._network_metered_btn, self._tethering_toggle_btn, self._tethering_password_btn, @@ -91,8 +102,7 @@ class NetworkLayoutMici(NavScroller): def _update_state(self): super()._update_state() - # If not using prime SIM, show GSM settings and enable IPv4 forwarding - show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE) + show_cell_settings = not ui_state.prime_state.is_full_prime() self._wifi_manager.set_ipv4_forward(show_cell_settings) self._roaming_btn.set_visible(show_cell_settings) self._apn_btn.set_visible(show_cell_settings) @@ -102,14 +112,16 @@ class NetworkLayoutMici(NavScroller): super().show_event() self._wifi_manager.set_active(True) - # Process wifi callbacks while at any point in the nav stack + # Process wifi and esim callbacks while at any point in the nav stack gui_app.add_nav_stack_tick(self._wifi_manager.process_callbacks) + gui_app.add_nav_stack_tick(self._cellular_manager.process_callbacks) def hide_event(self): super().hide_event() self._wifi_manager.set_active(False) gui_app.remove_nav_stack_tick(self._wifi_manager.process_callbacks) + gui_app.remove_nav_stack_tick(self._cellular_manager.process_callbacks) def _edit_apn(self): def update_apn(apn: str): diff --git a/openpilot/system/ui/lib/cellular_manager.py b/openpilot/system/ui/lib/cellular_manager.py new file mode 100644 index 0000000000..97f20b5bea --- /dev/null +++ b/openpilot/system/ui/lib/cellular_manager.py @@ -0,0 +1,151 @@ +import time +import threading +from collections.abc import Callable +from dataclasses import replace + +from openpilot.common.hardware import HARDWARE +from openpilot.common.swaglog import cloudlog +from openpilot.common.esim.base import LPABase, Profile +from openpilot.common.esim.esim import execute_and_process_notifications + + +class CellularManager: + PROFILE_POLL_INTERVAL_S = 5.0 + + def __init__(self): + self._lpa: LPABase | None = None + self._profiles: list[Profile] = [] + self._busy: bool = False + # re-probed every poll; SIM may be swapped at runtime on tray-accessible devices + self._is_euicc: bool | None = None + self._modem_state: dict = {} + + self._lock = threading.Lock() + self._callback_lock = threading.Lock() + self._callback_queue: list[Callable] = [] + + self.on_profiles_updated: Callable[[], None] | None = None + self.on_operation_error: Callable[[str], None] | None = None + + self._last_profile_poll: float = 0.0 + self._polling: bool = False + + @property + def modem_state(self) -> dict: + return self._modem_state + + def process_callbacks(self): + with self._callback_lock: + to_run, self._callback_queue = self._callback_queue, [] + for cb in to_run: + cb() + + if not self._busy and not self._polling and time.monotonic() - self._last_profile_poll >= self.PROFILE_POLL_INTERVAL_S: + self._last_profile_poll = time.monotonic() + self._modem_state = HARDWARE.get_modem_state() + self._poll_profiles() + + @property + def profiles(self) -> list[Profile]: + return self._profiles + + @property + def active_profile(self) -> Profile | None: + return next((p for p in self._profiles if p.enabled), None) + + @property + def busy(self) -> bool: + return self._busy + + @property + def is_euicc(self) -> bool | None: + return self._is_euicc + + def _ensure_lpa(self) -> LPABase: + if self._lpa is None: + self._lpa = HARDWARE.get_sim_lpa() + return self._lpa + + def _enqueue(self, cb: Callable): + with self._callback_lock: + self._callback_queue.append(cb) + + def _stop_polling(self): + self._polling = False + + def _set_profiles(self, profiles: list[Profile]): + self._profiles = profiles + if self.on_profiles_updated: + self.on_profiles_updated() + + def _finish(self, profiles: list[Profile] | None = None, error: str | None = None): + self._busy = False + # defer the next poll a full interval; the eUICC can briefly report stale state after an operation + self._last_profile_poll = time.monotonic() + if profiles is not None: + self._set_profiles(profiles) + if error is not None: + self.refresh_profiles() + if self.on_operation_error: + self.on_operation_error(error) + + def _run_operation(self, fn: Callable[[LPABase], None], error_msg: str, relist: bool = True): + self._busy = True + + def worker(): + try: + with self._lock: + lpa = self._ensure_lpa() + fn(lpa) + profiles = lpa.list_profiles() if relist else None + self._enqueue(lambda: self._finish(profiles=profiles)) + except Exception as e: + cloudlog.exception(error_msg) + err = str(e) + self._enqueue(lambda: self._finish(error=err)) + + threading.Thread(target=worker, daemon=True).start() + + def refresh_profiles(self): + # next process_callbacks tick polls, respecting busy/polling guards + self._last_profile_poll = 0.0 + + def _poll_profiles(self): + self._polling = True + def worker(): + try: + with self._lock: + lpa = self._ensure_lpa() + is_euicc = lpa.is_euicc() + profiles = lpa.list_profiles() if is_euicc else [] + self._enqueue(lambda: self._finish_poll(is_euicc, profiles)) + except Exception: + cloudlog.exception("Failed to poll eSIM profiles") + self._enqueue(self._stop_polling) + + threading.Thread(target=worker, daemon=True).start() + + def _finish_poll(self, is_euicc: bool, profiles: list[Profile]): + self._polling = False + if self._busy: + return + if not is_euicc and self._is_euicc: + # is_euicc() is False on any AT error (e.g. SIM busy during a profile refresh); confirm on the next poll + self._is_euicc = None + return + self._is_euicc = is_euicc + self._set_profiles(profiles) + + def switch_profile(self, iccid: str): + def switch(lpa: LPABase): + execute_and_process_notifications(lpa, lambda: lpa.switch_profile(iccid)) + + # optimistic: list_profiles() can briefly return stale enabled state after a switch + self._set_profiles([replace(p, enabled=(p.iccid == iccid)) for p in self._profiles]) + self._run_operation(switch, "Failed to switch eSIM profile", relist=False) + + def delete_profile(self, iccid: str): + self._run_operation(lambda lpa: lpa.delete_profile(iccid), "Failed to delete eSIM profile") + + def nickname_profile(self, iccid: str, nickname: str): + self._run_operation(lambda lpa: lpa.nickname_profile(iccid, nickname), "Failed to update eSIM profile nickname") From 9946c521f78e3b2b2896d9cd1af53ecefa700c79 Mon Sep 17 00:00:00 2001 From: Pramish <139780421+pramishpy@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:16:21 -0500 Subject: [PATCH 036/270] modeld: build DM warp and model JITs for all camera configs (#38767) * modeld: build DM warp and model JITs for all camera configs In #38684, camera_configs on comma_arm64 was restricted to only the host device running SCons (selecting either _os_fisheye or _ar_ox_fisheye). However, prebuilt release and nightly bundles are built on TICI/tizi runners in Jenkins and distributed to both Comma 3/3X (TICI) and Comma 4 (MICI) devices. Because the build machine was TICI, dm_warp_1344x760_tinygrad.pkl was omitted from the packaged bundle, causing dmonitoringmodeld to fail with FileNotFoundError on Comma 4 and leaving driver-monitoring calibration stuck at 0%. Restore CAMERA_CONFIGS so both camera resolutions (1928x1208 and 1344x760) are built for all platforms. Fixes #38762 * simplify --------- Co-authored-by: ZwX1616 --- openpilot/selfdrive/modeld/SConscript | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index af10467529..41e9e05d0c 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -23,14 +23,12 @@ def estimate_pickle_max_size(onnx_size): # the ONNX size. Overestimating only adds an empty trailing chunk. return 2.0 * onnx_size + 10 * 1024 * 1024 +camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)] + if arch == 'comma_arm64': - from openpilot.common.hardware import HARDWARE - camera = _os_fisheye if HARDWARE.get_device_type() == "mici" else _ar_ox_fisheye - camera_configs = [(camera.width, camera.height)] tg_backend = 'QCOM' tg_flags = f'DEV={tg_backend} IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' else: - camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)] tg_backend = 'CPU' tg_flags = f'DEV=CPU' if arch == 'Darwin' else 'DEV=CPU:LLVM' From 7bfe6ba4d0c70744321b698657c745f6f677c687 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:21:03 -0700 Subject: [PATCH 037/270] pandad: allow disabling driver camera IR offroad (#38812) --- openpilot/common/params_keys.h | 1 + openpilot/selfdrive/pandad/pandad.cc | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index ba6eae3dd5..4b87356aca 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -28,6 +28,7 @@ inline static std::unordered_map keys = { {"ControlsReady", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"CurrentBootlog", {PERSISTENT, STRING}}, {"CurrentRoute", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, STRING}}, + {"DisableDriverCameraIR", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"DisableLogging", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"DisablePowerDown", {PERSISTENT, BOOL}}, {"DisableUpdates", {PERSISTENT, BOOL}}, diff --git a/openpilot/selfdrive/pandad/pandad.cc b/openpilot/selfdrive/pandad/pandad.cc index d0b65586aa..8b54ca6eaf 100644 --- a/openpilot/selfdrive/pandad/pandad.cc +++ b/openpilot/selfdrive/pandad/pandad.cc @@ -328,8 +328,8 @@ void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control, } } - // Disable IR on input timeout - if (nanos_since_boot() - last_cabin_camera_t > 1e9) { + // Disable IR on input timeout or when requested offroad. + if (nanos_since_boot() - last_cabin_camera_t > 1e9 || (!is_onroad && params.getBool("DisableDriverCameraIR"))) { ir_pwr = 0; } From 60d8ae2d4f1a55a44188b3ba0b31a6e8d07122c5 Mon Sep 17 00:00:00 2001 From: ZwX1616 Date: Tue, 8 Sep 2026 15:27:11 -0700 Subject: [PATCH 038/270] modeld/SConscript: fix chunk_targets estimate (#38821) --- openpilot/selfdrive/modeld/SConscript | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 41e9e05d0c..9a10fed585 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -81,7 +81,7 @@ for chestnut in [False, True] if CHESTNUT else [False]: f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} ' f'--output {target_pkl_path} --frame-skip {frame_skip}') onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps) - chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) + chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum * len(camera_configs))) def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets): from openpilot.system.hardware.chestnut.flash import link_up # chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars From 23d0474b95aa8f903e7561195c8aa586029773c3 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 8 Sep 2026 15:37:12 -0700 Subject: [PATCH 039/270] compile big model in jenkins CI (#38822) compile chestnut in ci --- Jenkinsfile | 5 +++++ openpilot/selfdrive/test/chestnut.sh | 7 +++++++ openpilot/selfdrive/test/setup_device_ci.sh | 6 ++++++ 3 files changed, 18 insertions(+) create mode 100755 openpilot/selfdrive/test/chestnut.sh diff --git a/Jenkinsfile b/Jenkinsfile index af33edb821..f1450c304b 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -252,6 +252,11 @@ node { step("test amp", "./openpilot/common/hardware/comma/tests/test_amplifier.py"), ]) }, + 'chestnut': { + deviceStage("chestnut", "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [ + step("compile big model", "./openpilot/selfdrive/test/chestnut.sh"), + ]) + }, ) } diff --git a/openpilot/selfdrive/test/chestnut.sh b/openpilot/selfdrive/test/chestnut.sh new file mode 100755 index 0000000000..747a2b275b --- /dev/null +++ b/openpilot/selfdrive/test/chestnut.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -e + +TARGET=openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest +rm -f "$TARGET" +scons --cache-disable "$TARGET" +test -s "$TARGET" diff --git a/openpilot/selfdrive/test/setup_device_ci.sh b/openpilot/selfdrive/test/setup_device_ci.sh index 60631ce037..8558a38bc9 100755 --- a/openpilot/selfdrive/test/setup_device_ci.sh +++ b/openpilot/selfdrive/test/setup_device_ci.sh @@ -58,6 +58,12 @@ chmod +x $CONTINUE_PATH export GIT_LFS_SKIP_SMUDGE=1 pull_lfs() { + if [ -n "${CHESTNUT:-}" ] + then + git lfs pull --exclude='' + return + fi + # The big driving model is not used on these devices yet. Keep its pointer in # the worktree, but don't download or copy the 1.8 GB LFS object. LFS_EXCLUDE="openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" From 96be8c7eb57740f429da75932d57ffe0ac83811d Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Tue, 8 Sep 2026 16:46:19 -0700 Subject: [PATCH 040/270] check dependency footprint (#38825) --- scripts/lint/check_dependencies.py | 37 ++++++++++++++++++++++++++++++ scripts/lint/lint.sh | 2 ++ 2 files changed, 39 insertions(+) create mode 100755 scripts/lint/check_dependencies.py diff --git a/scripts/lint/check_dependencies.py b/scripts/lint/check_dependencies.py new file mode 100755 index 0000000000..f3707035b3 --- /dev/null +++ b/scripts/lint/check_dependencies.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +import sys +import tomllib +import importlib.metadata +from pathlib import Path + + +def main() -> int: + if sys.prefix == sys.base_prefix: + print("Dependency checks require a virtual environment. Run tools/op.sh setup first.") + return 1 + + project = tomllib.loads((Path(__file__).resolve().parents[2] / "pyproject.toml").read_text())["project"] + direct = len(project["dependencies"]) + sum(len(deps) for deps in project["optional-dependencies"].values()) + # Count each installed package once, including transitive dependencies and all extras. + packages = {dist.metadata["Name"].lower().replace("_", "-") for dist in importlib.metadata.distributions()} + # Logical file sizes avoid filesystem block-size differences. Don't follow links to the interpreter or source tree. + size = sum(path.stat().st_size for path in Path(sys.prefix).rglob("*") if not path.is_symlink() and path.is_file()) + + """ + This test prevents our depency footprint from growing. + These values are *not* intended to be increased, and we + expect to strictly drive these down over time. + """ + failed = False + for name, value, limit in ( + ("Direct dependencies (all extras)", direct, 37), + ("Total dependencies", len(packages), 65), + ("Venv size (MiB)", size / 1024**2, 550), + ): + print(f"{name}: {value:g} (limit: {limit})") + failed |= value > limit + return int(failed) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lint/lint.sh b/scripts/lint/lint.sh index bfd93f23a9..fe3644d670 100755 --- a/scripts/lint/lint.sh +++ b/scripts/lint/lint.sh @@ -46,6 +46,7 @@ function run_tests() { PYTHON_FILES=$2 run "ruff" ruff check openpilot --quiet + run "check_dependencies" python3 $DIR/check_dependencies.py run "check_indentation" $DIR/check_indentation.py $PYTHON_FILES run "check_added_large_files" $DIR/check_added_large_files.py --maxkb=120 $ALL_FILES run "check_shebang_scripts_are_executable" $DIR/check_shebang_scripts_are_executable.py $ALL_FILES @@ -67,6 +68,7 @@ function help() { echo "" echo -e "${BOLD}${UNDERLINE}Tests:${NC}" echo -e " ${BOLD}ruff${NC}" + echo -e " ${BOLD}check_dependencies${NC}" echo -e " ${BOLD}check_indentation${NC}" echo -e " ${BOLD}ty${NC}" echo -e " ${BOLD}codespell${NC}" From f23e65768cd2b1bb59939990edef21d9be4ea860 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 8 Sep 2026 17:06:12 -0700 Subject: [PATCH 041/270] ui: resize status icons (#38749) * ui: resize status icons * Update orange GPU icon to 108px height * Trigger CI after LFS upload * ci --- .../assets/icons_mici/chestnut_orange.png | 4 ++-- openpilot/selfdrive/ui/mici/layouts/home.py | 24 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png b/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png index d3982abf87..41316e610c 100644 --- a/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png +++ b/openpilot/selfdrive/assets/icons_mici/chestnut_orange.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:845c40ff0d37612e8f2f482a36845744b5ae91ce2fcfc8117990d7d278b59820 -size 13079 +oid sha256:bf97a6738b294ac0aed9b2d075916cee0b7d3215bcd23381760900ede6a92748 +size 13256 diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index 553fa37f8c..9517701b50 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -77,21 +77,21 @@ class AlertsPill(Widget): class NetworkIcon(Widget): def __init__(self): super().__init__() - self.set_rect(rl.Rectangle(0, 0, 54, 44)) # max size of all icons + self.set_rect(rl.Rectangle(0, 0, 60, 47)) # max size of all icons self._net_type = NetworkType.none self._net_strength = 0 - self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 50, 44) - self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 50, 37) - self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 50, 37) - self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 50, 37) - self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 50, 37) + self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 54, 47) + self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 54, 40) + self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 54, 40) + self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 54, 40) + self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 54, 40) - self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 54, 36) - self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 54, 36) - self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 54, 36) - self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 54, 36) - self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 54, 36) + self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 60, 40) + self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 60, 40) + self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 60, 40) + self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 60, 40) + self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 60, 40) def _update_state(self): device_state = ui_state.sm['deviceState'] @@ -141,7 +141,7 @@ class MiciHomeLayout(Widget): self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) self._usb_icon = IconWidget("icons_mici/usb.png", (62, 40)) - self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (68, 40)) + self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (54, 40)) self._chestnut_loading_icon = IconWidget("icons_mici/chestnut.png", (68, 40)) self._chestnut_failed_icon = IconWidget("icons_mici/chestnut_orange.png", (68, 40)) self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) From dd4615850cd8e265ba25c16fa14ec98368599c64 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 8 Sep 2026 18:05:03 -0700 Subject: [PATCH 042/270] replay chestnut in CI (#38826) run model replay on chestnut in CI --- Jenkinsfile | 3 ++- openpilot/selfdrive/test/chestnut.sh | 2 +- openpilot/selfdrive/test/process_replay/model_replay.py | 9 +++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index f1450c304b..b6b3eec5e3 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -254,7 +254,8 @@ node { }, 'chestnut': { deviceStage("chestnut", "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [ - step("compile big model", "./openpilot/selfdrive/test/chestnut.sh"), + step("build", "./openpilot/selfdrive/test/chestnut.sh"), + step("model replay", "openpilot/selfdrive/test/process_replay/model_replay.py --chestnut"), ]) }, diff --git a/openpilot/selfdrive/test/chestnut.sh b/openpilot/selfdrive/test/chestnut.sh index 747a2b275b..1ffd12c9e4 100755 --- a/openpilot/selfdrive/test/chestnut.sh +++ b/openpilot/selfdrive/test/chestnut.sh @@ -3,5 +3,5 @@ set -e TARGET=openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest rm -f "$TARGET" -scons --cache-disable "$TARGET" +SCONSFLAGS="-j2 --cache-disable" ./openpilot/system/manager/build.py test -s "$TARGET" diff --git a/openpilot/selfdrive/test/process_replay/model_replay.py b/openpilot/selfdrive/test/process_replay/model_replay.py index 927c9b38f1..47ca3fb104 100755 --- a/openpilot/selfdrive/test/process_replay/model_replay.py +++ b/openpilot/selfdrive/test/process_replay/model_replay.py @@ -25,6 +25,8 @@ SEGMENT = 4 START_FRAME = 0 END_FRAME = 60 +CHESTNUT = "--chestnut" in sys.argv + SEND_EXTRA_INPUTS = bool(int(os.getenv("SEND_EXTRA_INPUTS", "0"))) DATA_TOKEN = os.getenv("CI_ARTIFACTS_TOKEN","") @@ -39,7 +41,7 @@ EXEC_TIMINGS = [ ] def get_log_fn(test_route, ref="master"): - return f"{test_route}_model_tici_{ref}.zst" + return f"{test_route}_model_{'chestnut' if CHESTNUT else 'tici'}_{ref}.zst" def plot(proposed, master, title, tmp): proposed = list(proposed) @@ -170,6 +172,8 @@ def model_replay(lr, frs): msgs = modeld_msgs + dmonitoringmodeld_msgs chestnut = any(m.modelV2.big for m in modeld_msgs if m.which() == "modelV2") + if CHESTNUT: + assert chestnut and all(m.modelV2.big for m in modeld_msgs if m.which() == "modelV2"), "Chestnut replay must run the big model without fallback" header = ['model', 'max instant', 'max instant allowed', 'average', 'max average allowed', 'test result'] rows = [] @@ -285,7 +289,8 @@ if __name__ == "__main__": diff_short, diff_long, failed = format_diff(results, log_paths, 'master') if "CI" in os.environ: - comment_replay_report(log_msgs, cmp_log, log_msgs) + if not CHESTNUT: + comment_replay_report(log_msgs, cmp_log, log_msgs) failed = False print(diff_long) print('-------------\n'*5) From 1fb3edb7dd568b3ba0e304ac60d91d38a85a1b76 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:53:33 -0700 Subject: [PATCH 043/270] qrcode: test alignment pattern positions (#38829) --- openpilot/common/tests/test_qrcode.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 openpilot/common/tests/test_qrcode.py diff --git a/openpilot/common/tests/test_qrcode.py b/openpilot/common/tests/test_qrcode.py new file mode 100644 index 0000000000..2ff5b0846c --- /dev/null +++ b/openpilot/common/tests/test_qrcode.py @@ -0,0 +1,14 @@ +import unittest + +from openpilot.common import qrcode as qr +from openpilot.common.test import OpenpilotTestCase + + +class TestQRCode(OpenpilotTestCase): + def test_alignment_positions(self): + assert qr._alignment_positions(7) == [6, 22, 38] + assert qr._alignment_positions(40) == [6, 30, 58, 86, 114, 142, 170] + + @unittest.expectedFailure # the step between patterns rounds the wrong way for version 32 + def test_alignment_positions_v32(self): + assert qr._alignment_positions(32) == [6, 34, 60, 86, 112, 138] From a4a24d5d87a90f01f69665da623d4e027b923edb Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:05:18 -0700 Subject: [PATCH 044/270] qrcode: fix alignment pattern positions for version 32 (#38827) The closed-form step between alignment patterns rounds the wrong way for version 32 (28 instead of 26). Use the form that is right for every version. The encoder only emits versions 1-20, so its output is unchanged. --- openpilot/common/qrcode.py | 2 +- openpilot/common/tests/test_qrcode.py | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/openpilot/common/qrcode.py b/openpilot/common/qrcode.py index b6b345c227..f77d8a29ee 100644 --- a/openpilot/common/qrcode.py +++ b/openpilot/common/qrcode.py @@ -108,7 +108,7 @@ def _alignment_positions(version: int) -> list[int]: if version == 1: return [] count = version // 7 + 2 - step = ((version * 4 + count * 2 + 1) // (count * 2 - 2)) * 2 + step = (version * 8 + count * 3 + 5) // (count * 4 - 4) * 2 return [6] + [version * 4 + 10 - step * i for i in range(count - 1)][::-1] diff --git a/openpilot/common/tests/test_qrcode.py b/openpilot/common/tests/test_qrcode.py index 2ff5b0846c..6c7d8c6f47 100644 --- a/openpilot/common/tests/test_qrcode.py +++ b/openpilot/common/tests/test_qrcode.py @@ -1,5 +1,3 @@ -import unittest - from openpilot.common import qrcode as qr from openpilot.common.test import OpenpilotTestCase @@ -7,8 +5,5 @@ from openpilot.common.test import OpenpilotTestCase class TestQRCode(OpenpilotTestCase): def test_alignment_positions(self): assert qr._alignment_positions(7) == [6, 22, 38] - assert qr._alignment_positions(40) == [6, 30, 58, 86, 114, 142, 170] - - @unittest.expectedFailure # the step between patterns rounds the wrong way for version 32 - def test_alignment_positions_v32(self): assert qr._alignment_positions(32) == [6, 34, 60, 86, 112, 138] + assert qr._alignment_positions(40) == [6, 30, 58, 86, 114, 142, 170] From ef769c173d9d5034114280f0b98a351c80ebcc3e Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:15:39 -0700 Subject: [PATCH 045/270] qrcode: generalize EC, format, and GF tables (#38828) Replace the level-L-only Reed-Solomon parameters, the single hardcoded format word, and the bitwise GF(256) multiply with the full EC table, the 32 format words, and log/antilog tables. Block splitting and interleaving move into _block_lengths and _interleaved. This is the structure a decoder needs to read any version, level, and mask; the encoder's module matrices are unchanged and test_encoder pins them. --- openpilot/common/qrcode.py | 91 ++++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 33 deletions(-) diff --git a/openpilot/common/qrcode.py b/openpilot/common/qrcode.py index f77d8a29ee..634beb94e0 100644 --- a/openpilot/common/qrcode.py +++ b/openpilot/common/qrcode.py @@ -4,13 +4,41 @@ import numpy as np import pyray as rl -# Indexes are QR versions. These are the only two Reed-Solomon parameters needed -# for error-correction level L. -_ECC_LEN = (0, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28) -_NUM_BLOCKS = (0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8) +# (ec codewords per block, block count) for levels L, M, Q, H, versions 1-40 +_EC = [ + ((7, 1), (10, 1), (13, 1), (17, 1)), ((10, 1), (16, 1), (22, 1), (28, 1)), ((15, 1), (26, 1), (18, 2), (22, 2)), + ((20, 1), (18, 2), (26, 2), (16, 4)), ((26, 1), (24, 2), (18, 4), (22, 4)), ((18, 2), (16, 4), (24, 4), (28, 4)), + ((20, 2), (18, 4), (18, 6), (26, 5)), ((24, 2), (22, 4), (22, 6), (26, 6)), ((30, 2), (22, 5), (20, 8), (24, 8)), + ((18, 4), (26, 5), (24, 8), (28, 8)), ((20, 4), (30, 5), (28, 8), (24, 11)), ((24, 4), (22, 8), (26, 10), (28, 11)), + ((26, 4), (22, 9), (24, 12), (22, 16)), ((30, 4), (24, 9), (20, 16), (24, 16)), ((22, 6), (24, 10), (30, 12), (24, 18)), + ((24, 6), (28, 10), (24, 17), (30, 16)), ((28, 6), (28, 11), (28, 16), (28, 19)), ((30, 6), (26, 13), (28, 18), (28, 21)), + ((28, 7), (26, 14), (26, 21), (26, 25)), ((28, 8), (26, 16), (30, 20), (28, 25)), ((28, 8), (26, 17), (28, 23), (30, 25)), + ((28, 9), (28, 17), (30, 23), (24, 34)), ((30, 9), (28, 18), (30, 25), (30, 30)), ((30, 10), (28, 20), (30, 27), (30, 32)), + ((26, 12), (28, 21), (30, 29), (30, 35)), ((28, 12), (28, 23), (28, 34), (30, 37)), ((30, 12), (28, 25), (30, 34), (30, 40)), + ((30, 13), (28, 26), (30, 35), (30, 42)), ((30, 14), (28, 28), (30, 38), (30, 45)), ((30, 15), (28, 29), (30, 40), (30, 48)), + ((30, 16), (28, 31), (30, 43), (30, 51)), ((30, 17), (28, 33), (30, 45), (30, 54)), ((30, 18), (28, 35), (30, 48), (30, 57)), + ((30, 19), (28, 37), (30, 51), (30, 60)), ((30, 19), (28, 38), (30, 53), (30, 63)), ((30, 20), (28, 40), (30, 56), (30, 66)), + ((30, 21), (28, 43), (30, 59), (30, 70)), ((30, 22), (28, 45), (30, 62), (30, 74)), ((30, 24), (28, 47), (30, 65), (30, 77)), + ((30, 25), (28, 49), (30, 68), (30, 81)), +] -# 15 format-info bits for level L (01) with mask 0: ((0x08 << 10) | bch_remainder) ^ 0x5412 -_FORMAT_BITS = 0b111011111000100 +# GF(256) with the QR polynomial x^8 + x^4 + x^3 + x^2 + 1: powers of alpha and their logs +_EXP = [1] +for _ in range(254): + _EXP.append(_EXP[-1] << 1 ^ (0x11D if _EXP[-1] & 0x80 else 0)) +_LOG = {v: i for i, v in enumerate(_EXP)} + + +def _bch_format(data: int) -> int: + v = data << 10 + for shift in range(14, 9, -1): + if v >> shift & 1: + v ^= 0x537 << (shift - 10) + return (data << 10 | v) ^ 0x5412 + + +# 15-bit format info indexed by (level bits << 3 | mask). Level bits: L=01, M=00, Q=11, H=10. +_FORMATS = [_bch_format(d) for d in range(32)] def _raw_modules(version: int) -> int: @@ -21,8 +49,24 @@ def _raw_modules(version: int) -> int: return result - (36 if version >= 7 else 0) +def _block_lengths(version: int, level: int) -> list[int]: + """Data codewords per Reed-Solomon block. The last blocks may be one longer.""" + ec, nblocks = _EC[version - 1][level] + total = _raw_modules(version) // 8 - ec * nblocks + return [total // nblocks + (i >= nblocks - total % nblocks) for i in range(nblocks)] + + +def _interleaved(version: int, level: int) -> list[tuple[int, int]]: + """(block, index within block) of each transmitted codeword: data column-major, then ECC column-major.""" + ec, nblocks = _EC[version - 1][level] + lens = _block_lengths(version, level) + data = [(b, i) for i in range(max(lens)) for b in range(nblocks) if i < lens[b]] + ecc = [(b, lens[b] + i) for i in range(ec) for b in range(nblocks)] + return data + ecc + + def _capacity(version: int) -> int: - return _raw_modules(version) // 8 - _ECC_LEN[version] * _NUM_BLOCKS[version] + return sum(_block_lengths(version, 0)) def _append_bits(bits: list[int], value: int, length: int) -> None: @@ -49,37 +93,18 @@ def _data_codewords(data: bytes, version: int) -> bytes: def _codewords(data: bytes, version: int) -> bytes: """Split data codewords into Reed-Solomon blocks and interleave data + ECC.""" data = _data_codewords(data, version) - num_blocks = _NUM_BLOCKS[version] - ecc_len = _ECC_LEN[version] - raw_codewords = _raw_modules(version) // 8 - short_len = raw_codewords // num_blocks - num_short = num_blocks - raw_codewords % num_blocks - divisor = _divisor(ecc_len) - blocks: list[tuple[bytes, bytes]] = [] + divisor = _divisor(_EC[version - 1][0][0]) + blocks = [] offset = 0 - for i in range(num_blocks): - length = short_len - ecc_len + (0 if i < num_short else 1) + for length in _block_lengths(version, 0): block = data[offset:offset + length] - blocks.append((block, _remainder(block, divisor))) + blocks.append(block + _remainder(block, divisor)) offset += length - result = bytearray() - for i in range(short_len - ecc_len + 1): - for block, _ in blocks: - result.extend(block[i:i + 1]) - for i in range(ecc_len): - for _, ecc in blocks: - result.append(ecc[i]) - return bytes(result) + return bytes(blocks[b][i] for b, i in _interleaved(version, 0)) def _multiply(x: int, y: int) -> int: - result = 0 - for _ in range(8): - result = (result << 1) ^ (0x11D if result & 0x80 else 0) - if y & 0x80: - result ^= x - y <<= 1 - return result + return _EXP[(_LOG[x] + _LOG[y]) % 255] if x and y else 0 def _divisor(degree: int) -> bytes: @@ -171,7 +196,7 @@ class _Qr: def _format(self) -> None: for i in range(15): - bit = ((_FORMAT_BITS >> i) & 1) != 0 + bit = ((_FORMATS[1 << 3 | 0] >> i) & 1) != 0 # level L, mask 0 y_pos = i if i < 6 else i + 1 if i < 8 else self.size - 15 + i self._set_function(8, y_pos, bit) x_pos = self.size - 1 - i if i < 8 else 15 - i if i < 9 else 14 - i From 66b4dbe2a13fee7868f737ca73f1559920d098dd Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:23:55 -0700 Subject: [PATCH 046/270] qrcode: add QR decoder (#38814) * qrcode: add QR decoder Decodes a QR code from a grayscale image or a module matrix: adaptive binarization, finder pattern search by run ratios, perspective sampling with alignment pattern refinement, format info, unmasking, block de-interleaving, Reed-Solomon correction, and numeric, alphanumeric, byte (with ECI), and Kanji segments. Fixtures are packed matrices from python-qrcode 8.2 covering every version and level, plus Segno 1.6.6 matrices for the ECI cases. * qrcode: tune decoder for the device Measured on a comma mici with real cabin frames downsampled to 672x380, the size the eSIM screen scans at. Per frame: 30 ms -> 8 ms with no code in view, 42 ms -> 13 ms with a code. - binarize: fixed two-radius fill from one integral image instead of an iterative outward fill (a dark cabin is almost all flat tiles), tile stats on a contiguous layout, flat tiles decided whole so sensor noise cannot become speckle, uint8 threshold compare - finder search: scan every 4th row, build column runs only at candidate columns - alignment search starts at a 2-module radius - Reed-Solomon syndromes through table lookups, placement order cached * qrcode: simplify decoder Fold the function-module mask into _data_coords and the format coordinates into _read_format, evaluate masks directly on the data coordinates, compact the ECI, numeric, and alphanumeric parsing, and flatten the retry loop in decode. No behavior change. --- openpilot/common/qrcode.py | 498 +++++++++++++++++- openpilot/common/tests/fixtures/qrcode_0.npz | Bin 0 -> 67335 bytes openpilot/common/tests/fixtures/qrcode_1.npz | Bin 0 -> 64985 bytes openpilot/common/tests/fixtures/qrcode_2.npz | Bin 0 -> 65540 bytes openpilot/common/tests/fixtures/qrcode_3.npz | Bin 0 -> 65135 bytes .../common/tests/fixtures/qrcode_eci.npz | Bin 0 -> 1810 bytes openpilot/common/tests/test_qrcode.py | 169 ++++++ 7 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 openpilot/common/tests/fixtures/qrcode_0.npz create mode 100644 openpilot/common/tests/fixtures/qrcode_1.npz create mode 100644 openpilot/common/tests/fixtures/qrcode_2.npz create mode 100644 openpilot/common/tests/fixtures/qrcode_3.npz create mode 100644 openpilot/common/tests/fixtures/qrcode_eci.npz diff --git a/openpilot/common/qrcode.py b/openpilot/common/qrcode.py index 634beb94e0..895daece39 100644 --- a/openpilot/common/qrcode.py +++ b/openpilot/common/qrcode.py @@ -1,4 +1,7 @@ -"""Small QR encoder for the UI's byte-mode, error-correction-level-L codes.""" +"""QR code encoding, decoding, and UI textures.""" + +import functools +import itertools import numpy as np import pyray as rl @@ -241,3 +244,496 @@ def make_texture(data: str, inverted: bool = False) -> rl.Texture: rl_image.mipmaps = 1 rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 return rl.load_texture_from_image(rl_image) + + +# ---- Symbol structure for decoding ---- + + +class QRError(Exception): + pass + + +_LEVELS = (1, 0, 3, 2) # format info level bits -> column in _EC + +_MASKS = [ + lambda i, j: (i + j) % 2 == 0, + lambda i, j: i % 2 == 0, + lambda i, j: j % 3 == 0, + lambda i, j: (i + j) % 3 == 0, + lambda i, j: (i // 2 + j // 3) % 2 == 0, + lambda i, j: (i * j) % 2 + (i * j) % 3 == 0, + lambda i, j: ((i * j) % 2 + (i * j) % 3) % 2 == 0, + lambda i, j: ((i + j) % 2 + (i * j) % 3) % 2 == 0, +] + +_ALIGNMENT = np.ones((5, 5), dtype=bool) +_ALIGNMENT[1:4, 1:4] = False +_ALIGNMENT[2, 2] = True + + +def _gf_inv(a: int) -> int: + return _EXP[-_LOG[a] % 255] + + +@functools.lru_cache +def _data_coords(version: int) -> tuple[np.ndarray, np.ndarray]: + """(rows, cols) of the data and error correction modules in placement order: two-column zigzag from the right.""" + dim = version * 4 + 17 + func = np.zeros((dim, dim), dtype=bool) # finder, timing, alignment, format, and version modules + func[:9, :9] = func[:9, dim - 8:] = func[dim - 8:, :9] = True + func[6, :] = func[:, 6] = True + positions = _alignment_positions(version) + for r, c in itertools.product(positions, positions): + if (r, c) not in ((6, 6), (6, dim - 7), (dim - 7, 6)): + func[r - 2:r + 3, c - 2:c + 3] = True + if version >= 7: + func[:6, dim - 11:dim - 8] = func[dim - 11:dim - 8, :6] = True + ys = np.arange(dim) + rows, cols = [], [] + # the vertical timing column is skipped, so the pairs left of it start at odd columns + for i, right in enumerate(col if col > 6 else col - 1 for col in range(dim - 1, 0, -2)): + r = np.repeat(ys[::-1] if i % 2 == 0 else ys, 2) + c = np.tile((right, right - 1), dim) + keep = ~func[r, c] + rows.append(r[keep]) + cols.append(c[keep]) + return np.concatenate(rows), np.concatenate(cols) + + +# ---- Matrix decoding ---- + + +def _poly_eval(p: list[int], x: int) -> int: + # p is highest degree first + y = 0 + for c in p: + y = _multiply(y, x) ^ c + return y + + +_EXP_TABLE = np.array(_EXP) +_LOG_TABLE = np.array([_LOG.get(v, 0) for v in range(256)]) + + +def _syndromes(msg: list[int], nsym: int) -> list[int]: + """syn[i] = msg(alpha^i), msg highest degree first.""" + m = np.array(msg) + exponents = np.arange(nsym)[:, None] * (len(msg) - 1 - np.arange(len(msg))) + return np.bitwise_xor.reduce(_EXP_TABLE[(_LOG_TABLE[m] + exponents) % 255] * (m != 0), axis=1).tolist() + + +def _rs_correct(msg: list[int], nsym: int) -> list[int]: + """Corrects up to nsym // 2 errors in a Reed-Solomon codeword, in place.""" + n = len(msg) + syn = _syndromes(msg, nsym) + if not any(syn): + return msg + + # Berlekamp-Massey, sigma is lowest degree first + sigma, prev, L, m, b = [1], [1], 0, 1, 1 + for r in range(nsym): + d = syn[r] + for i in range(1, L + 1): + d ^= _multiply(sigma[i], syn[r - i]) + if d == 0: + m += 1 + continue + coef = _multiply(d, _gf_inv(b)) + shifted = [0] * m + prev + saved = sigma[:] + sigma = sigma + [0] * max(0, len(shifted) - len(sigma)) + for i, c in enumerate(shifted): + sigma[i] ^= _multiply(coef, c) + if 2 * L <= r: + L, prev, b, m = r + 1 - L, saved, d, 1 + else: + m += 1 + sigma = sigma[:L + 1] + if 2 * L > nsym: + raise QRError("too many errors") + + # Chien search: codeword position p has locator alpha^(n-1-p) + positions = [p for p in range(n) if _poly_eval(sigma[::-1], _EXP[(p - n + 1) % 255]) == 0] + if len(positions) != L: + raise QRError("error locator mismatch") + + # solve syn[i] = sum_k e_k * X_k^i for the magnitudes e_k + xlog = [(n - 1 - p) % 255 for p in positions] + A = [[_EXP[(xlog[k] * i) % 255] for k in range(L)] + [syn[i]] for i in range(L)] + for col in range(L): + piv = next((r for r in range(col, L) if A[r][col]), None) + if piv is None: + raise QRError("singular") + A[col], A[piv] = A[piv], A[col] + inv = _gf_inv(A[col][col]) + A[col] = [_multiply(inv, v) for v in A[col]] + for r in range(L): + if r != col and A[r][col]: + f = A[r][col] + A[r] = [a ^ _multiply(f, c) for a, c in zip(A[r], A[col], strict=True)] + for k, p in enumerate(positions): + msg[p] ^= A[k][L] + + if any(_syndromes(msg, nsym)): + raise QRError("uncorrectable") + return msg + + +def _read_format(m: np.ndarray) -> int: + """Returns the closest format info (level bits << 3 | mask) from either copy.""" + dim = m.shape[0] + copies = ([(8, i) for i in range(6)] + [(8, 7), (8, 8), (7, 8)] + [(5 - i, 8) for i in range(6)], + [(dim - 1 - i, 8) for i in range(7)] + [(8, dim - 8 + i) for i in range(8)]) # (row, col), msb first + candidates = [] + for coords in copies: + bits = int("".join(str(int(m[r, c])) for r, c in coords), 2) + candidates += [((bits ^ f).bit_count(), i) for i, f in enumerate(_FORMATS)] + distance, fmt = min(candidates) + if distance > 3: + raise QRError("bad format info") + return fmt + + +_ALNUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:" + +_ECI_ENCODINGS = { + 0: "cp437", 2: "cp437", 1: "iso8859-1", 3: "iso8859-1", + **{i + 2: f"iso8859-{i}" for i in range(2, 17) if i != 12}, + 20: "shift_jis", 21: "cp1250", 22: "cp1251", 23: "cp1252", 24: "cp1256", + 25: "utf-16-be", 26: "utf-8", 27: "ascii", 170: "ascii", 28: "big5", 29: "gb18030", 30: "euc_kr", +} + + +class _Bits: + def __init__(self, data: list[int]): + self._value = int.from_bytes(bytes(data), "big") + self.remaining = len(data) * 8 + + def read(self, n: int) -> int: + if n > self.remaining: + raise QRError("bitstream underflow") + self.remaining -= n + return self._value >> self.remaining & (1 << n) - 1 + + def read_below(self, n: int, limit: int) -> int: + v = self.read(n) + if v >= limit: + raise QRError("value out of range") + return v + + +def _parse_data(data: list[int], version: int) -> str: + bits = _Bits(data) + out: list[str] = [] + encoding = None + band = 0 if version <= 9 else 1 if version <= 26 else 2 + while bits.remaining >= 4: + mode = bits.read(4) + if mode == 0: + break + if mode == 7: # ECI character set assignment + first = bits.read(8) + extra = 0 if first < 0x80 else 8 if first < 0xC0 else 16 if first < 0xE0 else -1 # 1, 2, or 3 byte assignment + if extra < 0: + raise QRError("bad ECI assignment") + assignment = (first & 0x7F >> extra // 8) << extra | bits.read(extra) + encoding = _ECI_ENCODINGS.get(assignment) + if encoding is None: + raise QRError(f"unsupported ECI assignment {assignment}") + elif mode == 1: + n = bits.read((10, 12, 14)[band]) + while n > 0: + k = min(n, 3) # 3 digits in 10 bits, the last 2 or 1 in 7 or 4 + out.append(f"{bits.read_below((4, 7, 10)[k - 1], 10 ** k):0{k}d}") + n -= k + elif mode == 2: + n = bits.read((9, 11, 13)[band]) + while n > 0: + k = min(n, 2) # 2 characters in 11 bits, a last one in 6 + v = bits.read_below((6, 11)[k - 1], 45 ** k) + out.append(_ALNUM[v // 45] * (k - 1) + _ALNUM[v % 45]) + n -= k + elif mode == 4: + n = bits.read((8, 16, 16)[band]) + segment = bytes(bits.read(8) for _ in range(n)) + try: + out.append(segment.decode(encoding or "utf-8")) + except UnicodeDecodeError as e: + if encoding is not None: + raise QRError("invalid ECI byte segment") from e + out.append(segment.decode("latin-1")) + elif mode == 8: + n = bits.read((8, 10, 12)[band]) + for _ in range(n): + v = bits.read(13) + c = (v // 0xC0) << 8 | v % 0xC0 + c += 0x8140 if c < 0x1F00 else 0xC140 + try: + out.append(c.to_bytes(2, "big").decode("shift_jis")) + except UnicodeDecodeError as e: + raise QRError("invalid Kanji character") from e + else: + raise QRError(f"unsupported mode {mode}") + return "".join(out) + + +def decode_matrix(m: np.ndarray) -> str: + """Decodes a square boolean module matrix (True = dark) without a quiet zone.""" + dim = m.shape[0] + if m.shape != (dim, dim) or dim % 4 != 1 or not 21 <= dim <= 177: + raise QRError("bad matrix size") + version = (dim - 17) // 4 + + fmt = _read_format(m) + level = _LEVELS[fmt >> 3] + rows, cols = _data_coords(version) + bits = m[rows, cols] ^ _MASKS[fmt & 7](rows, cols) + codewords = np.packbits(bits[:len(bits) // 8 * 8]).tolist() + + ec, _ = _EC[version - 1][level] + lens = _block_lengths(version, level) + blocks = [[0] * (n + ec) for n in lens] + for (b, i), codeword in zip(_interleaved(version, level), codewords, strict=True): + blocks[b][i] = codeword + + data: list[int] = [] + for block, n in zip(blocks, lens, strict=True): + data += _rs_correct(block, ec)[:n] + return _parse_data(data, version) + + +# ---- Image decoding ---- + + +def _box_sums(a: np.ndarray, radii: tuple[int, ...]) -> list[np.ndarray]: + """Sums over (2r + 1)^2 neighborhoods of the last two axes, edge padded, from one integral image.""" + P = max(radii) + lead = [(0, 0)] * (a.ndim - 2) + cs = np.pad(np.cumsum(np.cumsum(np.pad(a, lead + [(P, P), (P, P)], mode="edge"), -2), -1), lead + [(1, 0), (1, 0)]) + H, W = a.shape[-2:] + out = [] + for r in radii: + lo, hi = P - r, P + r + 1 + out.append(cs[..., hi:hi + H, hi:hi + W] - cs[..., lo:lo + H, hi:hi + W] - cs[..., hi:hi + H, lo:lo + W] + cs[..., lo:lo + H, lo:lo + W]) + return out + + +def _binarize(gray: np.ndarray) -> np.ndarray: + """Adaptive threshold: each pixel against the mean of the surrounding tiles that have contrast.""" + h, w = gray.shape + if h < 21 or w < 21: + raise QRError("image too small") + B = max(8, min(h, w) // 128 * 2) + H, W = -(-h // B), -(-w // B) + padded = np.pad(gray, ((0, H * B - h), (0, W * B - w)), mode="edge") + # block statistics from a subsample are plenty + sub = np.ascontiguousarray(padded[::2, ::2].reshape(H, B // 2, W, B // 2).transpose(0, 2, 1, 3)).reshape(H, W, -1) + blocks = sub.sum(axis=2, dtype=np.uint32) / sub.shape[2] + known = sub.max(axis=2) - sub.min(axis=2) >= 32 + # Flat tiles cannot estimate their own threshold: use the tiles with contrast nearby, then + # further out, then the global midrange. A flat tile is then all dark or all light. + est = np.full((H, W), (blocks.min() + blocks.max()) / 2) + filled = np.zeros((H, W), dtype=bool) + for total, count in _box_sums(np.stack((known * blocks, known.astype(float))), (2, 6)): + fill = ~filled & (count > 0) + est[fill] = total[fill] / count[fill] + filled |= fill + thr = np.where(known, np.minimum(est, 254) + 1, np.where(blocks <= est, 255, 0)).astype(np.uint8) + return (padded.reshape(H, B, W, B) < thr[:, None, :, None]).reshape(H * B, W * B)[:h, :w] + + +class _Runs: + """Run-length table of a padded, flattened binary image with a per-pixel run index.""" + + def __init__(self, padded: np.ndarray): + self.flat = padded.ravel() + self.lines, self.stride = padded.shape + change = self.flat[1:] != self.flat[:-1] + self.starts = np.concatenate(([0], np.flatnonzero(change) + 1)) + self.lengths = np.diff(np.append(self.starts, self.flat.size)).astype(np.int32) + + def run_at(self, line: np.ndarray, pos: np.ndarray) -> np.ndarray: + """Index of the run containing the pixel at `pos` along `line`.""" + return np.searchsorted(self.starts, line * self.stride + pos + 1, side="right") - 1 + + @staticmethod + def _match(lengths: list[np.ndarray], ratios: tuple[int, ...]) -> tuple[np.ndarray, np.ndarray]: + """Checks windows of runs against the ratios, given the length of each run. Returns (ok, module size).""" + S = sum(ratios) + total = sum(lengths[1:], start=lengths[0]) + ok = total >= 2 * S # modules need to be at least 2 px + for L, r in zip(lengths, ratios, strict=True): + ok &= np.abs(2 * S * L - 2 * r * total) <= r * total # integer form of |L - r * total / S| <= r * total / (2 * S) + return ok, total / S + + def scan(self, ratios: tuple[int, ...]) -> np.ndarray: + """Returns the indices of all dark runs starting a window of runs matching the ratios.""" + n = len(ratios) + N = len(self.lengths) - n + 1 + if N <= 0: + return np.zeros(0, dtype=int) + ok, _ = self._match([self.lengths[k:N + k] for k in range(n)], ratios) + ok &= self.flat[self.starts[:N]] + first = np.flatnonzero(ok) + return first[self.starts[first] // self.stride == self.starts[first + n - 1] // self.stride] + + def check(self, first: np.ndarray, ratios: tuple[int, ...]) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Checks the run windows starting at run index `first`. Returns (ok, center position along the line, module size).""" + n, half = len(ratios), len(ratios) // 2 + ok = (first >= 0) & (first + n <= len(self.starts)) + idx = np.clip(first[:, None] + np.arange(n), 0, len(self.starts) - 1) + matched, module = self._match([self.lengths[idx[:, k]] for k in range(n)], ratios) + ok &= matched & self.flat[self.starts[idx[:, 0]]] + ok &= self.starts[idx[:, 0]] // self.stride == self.starts[idx[:, -1]] // self.stride + center = self.starts[idx[:, half]] % self.stride - 1 + self.lengths[idx[:, half]] / 2 + return ok, center, module + + +def _find_patterns(binary: np.ndarray, ratios: tuple[int, ...]) -> list[tuple[float, float, float]]: + """Finds dark/light run patterns with the given module ratios. Returns (x, y, module size).""" + half = len(ratios) // 2 + step = 2 # the center rows of a 2 px finder pattern still get scanned twice + rows_t = _Runs(np.pad(binary[::step], ((0, 0), (1, 1)))) + first = rows_t.scan(ratios) + if len(first) == 0: + return [] + _, cx, hmod = rows_t.check(first, ratios) + row = rows_t.starts[first] // rows_t.stride * step + + xi = cx.astype(int) + xs, col = np.unique(xi, return_inverse=True) + cols_t = _Runs(np.pad(binary[:, xs].T, ((0, 0), (1, 1)))) + ok, cy, vmod = cols_t.check(cols_t.run_at(col, row) - half, ratios) + ok &= (0.5 <= vmod / hmod) & (vmod / hmod <= 2) + line = np.clip(np.rint(cy / step), 0, rows_t.lines - 1).astype(int) + ok2, cx2, hmod2 = rows_t.check(rows_t.run_at(line, xi) - half, ratios) + ok &= ok2 & (0.5 <= hmod2 / vmod) & (hmod2 / vmod <= 2) + + found: list[list[float]] = [] # [x, y, module, count] + for x, y, module in zip(cx2[ok], cy[ok], (hmod2[ok] + vmod[ok]) / 2, strict=True): + for f in found: + if abs(f[0] - x) <= f[2] and abs(f[1] - y) <= f[2] and 0.5 <= f[2] / module <= 2: + c = f[3] + f[0], f[1], f[2], f[3] = (f[0] * c + x) / (c + 1), (f[1] * c + y) / (c + 1), (f[2] * c + module) / (c + 1), c + 1 + break + else: + found.append([x, y, module, 1]) + found.sort(key=lambda f: -f[3]) + return [(f[0], f[1], f[2]) for f in found if f[3] >= 2] + + +def _pick_finders(patterns: list[tuple[float, float, float]]) -> tuple[np.ndarray, np.ndarray, np.ndarray, float]: + """Returns (top-left, top-right, bottom-left) centers and the module size of the most square-looking triple.""" + best = None + for a, b, c in itertools.combinations(patterns[:10], 3): + mods = sorted((a[2], b[2], c[2])) + if mods[2] / mods[0] > 1.5: + continue + pts = [np.array(p[:2]) for p in (a, b, c)] + d = [np.linalg.norm(pts[(i + 1) % 3] - pts[(i + 2) % 3]) for i in range(3)] + tl = int(np.argmax(d)) # opposite the hypotenuse + p1, p2 = pts[(tl + 1) % 3], pts[(tl + 2) % 3] + v1, v2 = p1 - pts[tl], p2 - pts[tl] + n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2) + if n1 == 0 or n2 == 0: + continue + cos = abs(np.dot(v1, v2)) / (n1 * n2) + if cos > 0.35 or not 0.6 <= n1 / n2 <= 1.6: + continue + score = cos + abs(np.log(n1 / n2)) + np.log(mods[2] / mods[0]) + if best is not None and score >= best[0]: + continue + if v1[0] * v2[1] - v1[1] * v2[0] < 0: + p1, p2 = p2, p1 + best = (score, pts[tl], p1, p2, float(sum(mods) / 3)) + if best is None: + raise QRError("no finder patterns") + return best[1:] + + +def _perspective(src: np.ndarray, dst: np.ndarray) -> np.ndarray: + """Homography mapping the four src points onto the four dst points.""" + A = [row for (x, y), (u, v) in zip(src, dst, strict=True) + for row in ([x, y, 1, 0, 0, 0, -u * x, -u * y], [0, 0, 0, x, y, 1, -v * x, -v * y])] + try: + h = np.linalg.solve(np.array(A, dtype=float), np.asarray(dst, dtype=float).ravel()) + except np.linalg.LinAlgError as e: + raise QRError("degenerate geometry") from e + return np.append(h, 1).reshape(3, 3) + + +def _transform(H: np.ndarray, pts: np.ndarray) -> np.ndarray: + p = np.column_stack((pts, np.ones(len(pts)))) @ H.T + return p[:, :2] / p[:, 2:3] + + +def _match_alignment(binary: np.ndarray, est: np.ndarray, offs: np.ndarray, r: int, module: float) -> np.ndarray | None: + h, w = binary.shape + dy = np.arange(max(0, int(est[1]) - r), min(h, int(est[1]) + r)) - est[1] + dx = np.arange(max(0, int(est[0]) - r), min(w, int(est[0]) + r)) - est[0] + if len(dy) == 0 or len(dx) == 0: + return None + y = np.rint(est[1] + dy[:, None, None] + offs[None, None, :, 1]).astype(int) + x = np.rint(est[0] + dx[None, :, None] + offs[None, None, :, 0]).astype(int) + valid = ((y >= 0) & (y < h) & (x >= 0) & (x < w)).all(axis=2) + samples = binary[np.clip(y, 0, h - 1), np.clip(x, 0, w - 1)] + score = np.where(valid, (samples == _ALIGNMENT.ravel()).sum(axis=2), 0) + if score.max() < 23: + return None + hits = np.argwhere(score == score.max()) + centers = np.column_stack((est[0] + dx[hits[:, 1]], est[1] + dy[hits[:, 0]])) + closest = centers[np.argmin(np.linalg.norm(centers - est, axis=1))] + return centers[np.linalg.norm(centers - closest, axis=1) <= module / 2].mean(axis=0) + + +def _locate_alignment(binary: np.ndarray, H: np.ndarray, center: float, module: float) -> np.ndarray | None: + """Template matches the 5x5 alignment pattern around its position estimated from H.""" + grid = np.mgrid[-2:3, -2:3].reshape(2, -1).T[:, ::-1] + center # (25, 2) module coords (x, y) + pts = _transform(H, grid) + # the affine estimate can be off in both position and local scale under perspective + for radius in (2, 4, 8, 16): + for scale in (1.0, 0.8, 1.25, 0.65, 1.5): + found = _match_alignment(binary, pts[12], (pts - pts[12]) * scale, int(module * radius), module) + if found is not None: + return found + return None + + +def _sample(binary: np.ndarray, tl: np.ndarray, tr: np.ndarray, bl: np.ndarray, module: float, dim: int, use_alignment: bool) -> np.ndarray: + src = np.array([(3.5, 3.5), (dim - 3.5, 3.5), (3.5, dim - 3.5), (dim - 3.5, dim - 3.5)]) + dst = np.array([tl, tr, bl, tr + bl - tl]) + H = _perspective(src, dst) + if use_alignment and dim > 21: + align = _locate_alignment(binary, H, dim - 6.5, module) + if align is not None: + src[3], dst[3] = (dim - 6.5, dim - 6.5), align + H = _perspective(src, dst) + + rows, cols = np.mgrid[0:dim, 0:dim] + pts = _transform(H, np.column_stack((cols.ravel() + 0.5, rows.ravel() + 0.5))) + xy = np.rint(pts).astype(int) + h, w = binary.shape + if (xy < 0).any() or (xy[:, 0] >= w).any() or (xy[:, 1] >= h).any(): + raise QRError("code extends outside image") + return binary[xy[:, 1], xy[:, 0]].reshape(dim, dim) + + +def decode(gray: np.ndarray) -> str | None: + """Decodes the QR code in a 2D uint8 grayscale image. Modules need to be at least 2 px. + Returns None if nothing could be decoded.""" + try: + binary = _binarize(gray) + tl, tr, bl, module = _pick_finders(_find_patterns(binary, (1, 1, 3, 1, 1))) + except QRError: + return None + + d = (np.linalg.norm(tr - tl) + np.linalg.norm(bl - tl)) / 2 + dim = int(round((d / module + 7 - 17) / 4)) * 4 + 17 + dims = [cand for cand in (dim, dim - 4, dim + 4) if 21 <= cand <= 177] + for cand, use_alignment, transpose in itertools.product(dims, (True, False), (False, True)): + try: + m = _sample(binary, tl, tr, bl, module, cand, use_alignment) + return decode_matrix(m.T if transpose else m) + except QRError: + pass + return None diff --git a/openpilot/common/tests/fixtures/qrcode_0.npz b/openpilot/common/tests/fixtures/qrcode_0.npz new file mode 100644 index 0000000000000000000000000000000000000000..96f550e5f439d278daf2229532464a4bd343c753 GIT binary patch literal 67335 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�y+sitk%_TL7d&^6dnH@u0|u{-%vdS{TYa>oY!9Qh`J`2!*~t zQ>ORf0j(x3gjLM}2StOIO3-ho1*2h>U$S(%%6Ix-zwb_Eu-u$<-8}g1o*0C<4Nf{f z*f`nV`zETibZ1%t#3Wc1_kW%%_}`X^GZrR)mR^Xa+#MLXJo>KV`xHSH za~;2D)S>lb;&|`3H^2Rk9S?50%+JI>NiPvT}L z1taLCi%fyQ8d;h$uN+p>pg$J4F^*I8%5oSJR6+N7Xp3kf zsV=BtDO@HmN6-E@->f6WwmqkVnK%nu za^8sJx1v$ZrZW7KB}7FBmB!;4;y1Qom?-zGc^PAm`abxSTi0ZT4W)nw6yo3 z!@C0m`xkwV>|ecS#W;#?x$)^s;8*PQ$i!>rGb~P4$qhwRuJPEb?p>D zTm-J&opGov1LO)>@cAN*x)dV@o*7eN6O~h72Am6I913hbVlkk;2(}+b*7~yj&-A?kOBc4#wc8Is>Vxj-l366 zo6VyQx4By((V->2xHf8mpH|3gZcpFd8n_i(>gWLb@_gZHs#J#b2HEeZv+d_gQkvFV z-%IC%BF1N#a)eEqI(?MogUxpxE;Rg88})-j=_*j~aM4~xy$G0lhYE5Ko9o=?Cc0gR z!Kat-@Y6ys;htTvWQRHGvylVNAH&Lgwi$<751a}HqnW7El6o_z5-y1D=($PhLqUif zc?AX|bYDknfxv+By-W%lZG+Um zU6miq!IptEgscrEtrIxiylU!Hxw@Mnr%Mh;dORMsX3}zvxng11lk5*ceWYlXY?$#` zbr7ko0&&fP?IUeV4k_@s;}j?2?b~l(-?+|T!lR*Um@6=yIv_`8Oi*ca0!+gRkOLIszU)!io~-JToxTHsFOo~hfE(88{Ai}i7Xu}>HEI^9dlgfGPYPr7^F;P`m<##|{M$)K$e(1$l{RoqI%61Re_fyAs1O0Ms69(0UPoH@nV_3BXnxw8I2Sd8N%i2MTppK)Sd6H%r;cDhRhq zMG2&+E~s=CF!Aw>kqoZLfwpx2OA+h63;T%XAH_rgilT!+H71N5146%z2X@U(p{0Kb zIehk-ogHS8!P|R41EAS#VWlG6t@lW%x(acjJ))5RpigHR?*8Ook-w?3k6CT?Qa0tWfMiK8|-Z+sP5 z5uz=`s$HU}G|XH)``-22(N%05P#KSD9ji3u3`jd@V6?9tAk@TA7 z`F41*geFL0Ak-A~ibA2D%YAtDn?OJlwO?q0-L6d^{G;8)c8ZE>zR*_ZqgBn4X~B^B z8#=Pkm6H{Dg}lgrpetR+t2OHncPZziu>|F8)Nii*?%I1P%M0~_g~f6N4T}_89tFy$ z&?6`Q@s3p)q|?xAJk{c8~mvPF0HQb(Iz>sVHzQ zSXT3`CDWtMXaI)(R1_ZZBblHpBx-Ufmf3`F&~Y5@wg6hgy7}A_?!+&^zX&G8$6S-dpDedk z?^#rXL07ZVOY18UZIk;1f5{|1e`>kV!T>X-Btc_-0++T8rtzrV`qvxPFD$BW|t-Nd9@fw%!h z0+CIqSqxLIG$J-n5@MpwVIPK;qgdl2hGMgkuxA#cfXBo}x5Ps!&H)nPP)5TgQ1<)TuTzq`El~DMxoW_WHM;^pr5#Ak6y#T?YiyXSJkMY(rJej zt1F}&of&h}x=vIc7q3hC+WMjO#Kfxpx$^m#{iuY$IAS~4wkpQ zhID!#?%u$h_R@h6GkfjS`peE1KzPg%*=a`eFH7uF_^;kQlw`X8Ct>*dj)f^a<706f z4rk8kww|yH|FJRRq$PWwwHx^(_mJ!)>CaXK;uxlck^RV(DK^7Ki*t{@39Jp^z6E&i zo_+N9%=?GtN`IOs%K)xCM73{(%MbdC&brQwp1@7JE*Rt2p(gkMIbbuRtAF(%2CG01 zuvK6Dw;AeB1#n>znEJx~j6!L)jARBCsIqvu^M>L3z4FX`+Hly~^x*&-Mua+n%R0vp zMkzY{P)`Pwbwd(U6I8|%z+~&7hl`j~$@;EVe@?>=JWWf(E0FYOb0)38?1!Hw(s(V| zYxui?tJip302_{8(L7zneGtdi^KG<#-<&Z_rCLvp>aYv=w1JDfR$**JZzo*9G-`Ug z29#m7wrr=WE*cIC5hGa^q93=IIJx%UY9(rM8U4|$5%NCn)5w3)55SL_K2L#+9N&Xx z*A~1m+QSBkGco**oh;=dRZdFht@b3ZnUaT$y{V)8Nxcg)v5QOf;n6jBI#E>?5-(r3 zi*vWpW{+$3kLzo*VOwpCWzuC<7MFYlfzd%au!2 z>-g4?`&~NB%&3g^XookRi7D711`wx_7J;7qzIZCS-4a(lE8y?hK{;HvF%s8u-*L8e z_CTE>)N-~sV|27 zPAl(q-uqZF%dww~s8HBU9{=U(TMSV zlRWkGOi904sgF&LuYqY@GkVPjkX3CSm0wCM9~USqZuWuudg}Yt4XEco7Hff5D$}xo zY*47f@T10@=yAJ#Gh6^BT#8 z0dOGE(`ZY+QuqFmtQ`)!1Bhqv;;W>6H9euPnZ+{k9|1=m8-@gJC%2v}XQ-FWcwz0E zQw_Lf26AqiAh(FVfBshq49qi^)A*>z4x5ZaEBb2`}g3Zc( zer*wW4>VOm*7Sui^pd`YTMfsGiH$xcD)*sd>!s~eXrt#9PruXg9-_IGRFI!hAkM1s(yCd$sDCrLAes8(d6cA{rp zHJCIQCLWEYTF^%tI4i{LV|0j1`3%J?{kvhXFZ~J6Q_VL4FY>TyXxs0GDdHUw1Xe-J zJ&V3+pujZ*%4;)>l9qhEAz=7BN(f&!&``!u05<(3VMGbUP%|Z_1HqH{j;Btct}U7G zrcYlCJG_O#3nPrKf{y#a!ckxf$GWny+-3I=h!NCA`Xwvt9lTFhsh-a2Tj=4a>{*5yZcxK?9&}mIyw?nylcXS*bQHs%s!~9u*ZC%%*Yb1$AK5 zm^IJlXnof^@~g+)O2Dfc3MdNSIEzyAUu3;cUmY13>G>&c5Y~dL-E!MHaO1AEa2qht zd!SfuOCx1CAGYH>4N1Mg%+NsXWbGdUM+zzaLt?#pB%Ot)W{8U--VN#`Bsq*fmxQF> zSCf;87r9_&0svZMG?9lS-izqDU+^oa+%Ns?1+OB5EPlJ*j^h=dCxa{<03Q29`N6?R zdw?4Zqs;*j>32H8FLdetbl}Dtq`v5ygz|J!JVuVbxesL83`Ddd`qn>wLoB4d`ZI3` z8*QSaTV00QI!ExTwW@UZ!u`o_CJbf<&%V_Ya+pu$2qV-$m(XB`hy$DKn&-cOI{VUG zeeLJA8v94eDKUj%8Og_IL1^m{!j#z4PYoQ^w7rn}T?fPLg5{>JgA+&wze3OmN7*G5 zWPBIa5p+VSJ=-{2^iRW&_8*m%GnRP)Em+=K=Nd3F&m>Y_Kt%_I!G4v_?&(pm;gQU; zri9}Hfe+@24E1hPvhYaqIrI*+1@ahB*1g7FRl_p@p@6Wqa?URjF6N+fCN7w8O)@Xq zZQX>oVjRA(nc{-cXG7ndYIhs8eN>HLCCF}))A}cohRo#{)+cfJdUzZm(9Cbk*{ej~ zwPWUR9M*R73IQ)#kC*>DWW^pG-T&_)%jgGDz`U50HZ=XA9S`9MMu40ONMy9Cv%(VNs-m0`jtg_MZ?+5uHrH@YF~vsP`g zm-As)kMIyJ5d*gq4j1Y$h8FTk%874C>VRc5LC9DH)wwsgy<~s3J=h*E+F$=bb>DG+ zeZN)J+WMLWc?m1E@zVH9=zCml-5vxD?cQOE_i!1rT7M;B2 zS?$$ty?Mk*(L{<_SYUlajnXOPgsC;NZB}FuFisiHaQzxdCK5^goLV(iz-?j5C6F7! z&wWYfDbhBwQ=qP-Q73LR(y*jvgRq&&ycGI=SM|rZJ?skj3mkc$*Pje}7u;wKP2Q$^ zZ=u>a0m**bHEDL?Vh@{@MrGF-N&ieXD4UsEsF=NDHI{ughsNRf=ji$qQ-|U8%r1a! zWhv!g9n*a7l!WUajfC7L>@Fv2R_Z*=!y%*JsfORS-BWEu2K%$qmqbOF3TzQfF>&(L z?%PjE|7Hwc1`^Dk@UNx$jbK~&yO|+re@<}a4HeU1vZe<=1Os2wJlQWKq8Qe&nv++e ztSg;1f$C`VcRex6WZa!zM{wU`SP5@OUia3mW`@{3QCS+u*3z1MsB2m}E}%qpXFb0k zyZdZ-FsQjvtlmjA$ii;~{4QP>rz%7E{xho11sBPnCcOlNL1e+7DnBtx|J7dG*eMss zv0gyH-g({uy`@2;Pblk%Ps~m{&0dS}KV3D`_y8D)aem!nOOO<-LWT% zR0EeX3!9>?%JKVB%pZwc#uJDQHPA5N&j^$oF~N(SvI&e*#)@~h-N+BI`5+1m8~-2+ zM;=aKh!bRR1Nz7&dgJC3Fcplf%e^fBntPVRlMbal$m6_(;!syzXMyR$gRZmEsT1Of zZF}@S8Uwjg%nlrkyh^-yp4KgLNT{RWioXp*2Sqtqfo_dN!zl8Zz;Ve$XkKc@t=*!9 z=Df)UEe5y*Ekbe5U$@LQR`X|HQPr^WYqK;<91f2Ajne5Dz0#)b3Gxo|^xp5Uy_W{y z0L!P)l@3c6!=O971Z6_XV~0Yt3E)g1XNW?hQ31;ZB!OP+=V^Ua^IJh(~m_ zIacT4A<+3eshZv5Vriz$aG7v7BIkJ9<~Nc-;Dl=0_KtR(4?CO4CrMQU3DU^YL_7_T zraJcYcA(jqPv~Wf{7$j;WXzGE#2N@ijhTDxWF@0&>}F%jxu{4uZf)dD6L>0R3$cEv z&JC*HQ7llR+$jvEV9Csu>4b3W0Td*}E-M#u+%41a!iae5cY8(U0tK+gUznxOlAg}j z&lJ&Z9#l(*Y}m4MU=h}=wm6xVe{&r}Rv@EjPCocR)%;?XF+FR%H3P8U809*&*tK%= z6q<_m2|*T7G6_TT;C1Cstq3{nN~fLdim87ISwh!z?XW*<1hHTu_Ls~rnFy3xe5>gG zPT3=_dtw$~MBQjSvBKaZ7=ygV!E)9#(kj29wO>aM@+7!U40w1JgW|-J8s9A*@1WSj zT-H;QAUMct3Yu=;FvkP7Gw6Q-p7{+$&#CCC-!E5;!Me!}Rig#{a8;gTF3rN!v%|K6 z9Cg8<{?DO*>90n5B1lp2;>iBaJZBYydR1BQ%S3B&tt6U6W%_~(kVfTH^cK6~4tc?$ zW@6R!%1szocbfn&3j$TH#w7JJb`^I=v$OMa`{=T=%pv4vbp@#ZO@;Ai% zGUCX>7D#BJwCpc?+ACiFjU5LFbW}Dszj4MHNZy-=dK|iRCL+}WMVr#{h`!ph0H0g& z=FDB=))u8D$7ti}4-ESko6NE@^dTmR&_Dy$le2QVoTH9ymoaaqQcQm|0| zji2lFi(!HS1$c>e;`GE@o=WwDUIc(cKcPt{*9gLrl?3%eG_al4(q1rWXex3mN2b00 zeqBJ;^s57FtUcC+z6!%0tb$`L>oA6Zd`JA`&A#7g!F_70w^yq2(p1}987GPD`oc*5 zHtQ!ug=pX1wZ)dGgA5cs)h1z$;KpE+HGlA6H~~md6)9({7iNb2eNFb{Y_$6EJTlNI zk6ggs$0ma{c+FaU&K__(H~~Fz&ym3kc4_fW_$Jk!pwGi^Udw2A@Al3fO&Y#2)*9uf zYC;VwXnM5Ll#FC5Ci{dBqy%`im7#de^{sx~se)ydG^rcghvq$o! zF1>?$P9glNlq^d53cdiY!ir+eGbE@_5;4(a>o6F)5}sYIlJ&$+yx{w)vW4f?5%Dj4A!} z#@HiLP>qo6$2we-e-2_IFI+*vxa6EyD!N9I0mid1ZCb!~O6n5PI=(2*c0rAWy(n8f zle?ePd(znG$l2gqVv@L^8SYTiRZf+?K%4!Y@&HI`j;w>Ty&&qOCu= ztB=IEbKZa5SR$UdS1)ujDy{c~CTBtd31BVUkobic#Y3|%HGd2!S}!6fcNRQ+je6MLEE(Rx>&;(J6co)#6Y%dS;1us0^|NdtG9 zIZSH$;sUTK;-JHK8BQG(wj7AFUkqIMuMWtP()q)n8pG`a1y4hCvsc)N|Tgycb{4b$@2U-xH~_ByTw5{TmJy@py$Cq(vQI zeA<{>G&-t5b;hq#MzxKVjs(EAw170en0B|Rjj>@dESlC7tVlVRX1OvcF}E|7Q zAn7&Nq${u6g$)YMR>tl+{DQ3I9`IyFF6Ru}BKA;b=GdGN57sFZupd?*cAJ7LS* zJkTSEqYD!KLV6kyWW$l*;~&mO#xAk)1!=2gHw2XjCSlApCBiD-*H6Wj2YTfGX`EH| z>#X{b?WPGzmVMUAP2)+eT@6TJN&N@#tv_WJ*xkJ(LF*?!<&ET%szX)aC{Lqm3B zLsk=JHa1RnPR1Vu2J;UE?W4C=r{7V=)6blP8IW&x~n|8*UuvEv-$VnRqxYd9D5nxNz(31 zhF-n1K!gtC)V@qO9EwV+8Ld7xf6{*JIMvs;d zIUM86^%3QoUzgTA@e1ua$q~R!ZMzP3*rM`p97@lpi8~VJa3|3X!p;lWMK;$@4cXPg z+*%C&9|D;k)K?zzDDu6ruCZ}NxQn9QG-OuNONzW`zV5w_31#IB7q_rjgGx%% zRU@jXB*J?Z7YVKi^D1x2e|Kr&Ej$Etal1QRCC`&ZV-@tWPiRj`aY18on7O-;Nd2)2 zYbr`4M^vTlkRwdP!AawkAnshwl~CjqKUdZn1Vo0@KWV%4&rMt^v#BlzTUJ6JkWqFDxgPc5+2B?gMyuq8TB6&)mp$4`WHjr z{g%pWEkLO)|HsH$IVf2(9LYB*N8SBlSVnc~=d(G5nu^US#F1nrPZ=0pYw?G($Jj9i zx)&m0HS;#l`cHj0f{B^OuHgV8_E0rjXvA}>qv3Si3jcg%P%+*z>)pOcPIH`KuEJ`P zeHn4qQtr`}L%+20X+oWapg?am%qCI^(^Ma{KqI9D%-ntBi9B0NL0weMi_Gt?GB%3B z+nTqFMir8~8m^VKU57>5?k*S(FdLi|c3bY8xrux++9Px2X#yeHKQ(rI4fETP<+phN z#n9b$o0v}vbK8*^9$5}y74h|PWb(JJ=t&#S+%raHHsr+&%Yj`nb3743jMBA0TId^) zK58BZUqO)G=s{@5p9Xu6v63AIq3hok6}33UVMwD5z#Px?q2@1pCu$~8&D^^CQ?A;a zMc^wzdvLHmMN5g`9;cU?+Dk!gb3MrPfgglu57nl_BD^eN=suaOstj`Sx)1AukTu8- za|J&hOv&_%zO&&(;9je50yrwYRro_xjC~W~<6FR+Sf1^O8yrC9O!NgZuBTDw6CYP1 z1w`PtPTdLF?Z>kULqI=>ph!KpBY7HRz^)lE)3;wgGL@wIdG@ zfN_2bc5&5rMi|FE1F;cM1rZnn@Nh$<6x}OrFTQ;PFg|MV+~NR|9VKk~5YE?ZcpNYQ z$Yba@a~_q3O?YjX05V#z049392w%7`GufB9!t75-;&(Wo6<{ql(JEIsNXIrVtPswr z$Yo9qa$1Bm{i-4i_Q_%D%yiPL^0p;xj|364stP51YToF?rE)%*I&*xE3m$s`!q5|< zR9a;RAfgsn0bFBPGQSkZNYX1dEDhlE+}A(XniIGe?7&>4=f0384}Zna+-C}=nGTPp zk=e1EC>do-F{-R{qA>8d`?pc@n`Dn@0ntpi(gO@x0)wLtBOKzH7-*QL3F_4ouYLnf zjAlT>46>(8+qB6o{ZWLo1N87f;pzW_7ImSB9Qlm64@{v))CAhx}D{CiWTGxzKMI z(Qc#Eyv=SeeLZ-VJ-LLSB&8K~j9&X;=xT>9T~M_T9)K0>Rn55T%}#{%=~1*+{&drz z_w~TkqG!QwQS<7H&zj@r4$`hN= z{cJd}Jx{V9?>?n_uuvIreia_%FV8NH0 z{)ctHV4tuRFY&C8|B~1j4B-4|8_?!oa;_I{CK4gA3K{^*z3<9YD`^eic~Cbzo%D+b zu=RTvthMP+vu?%qfB{G!^yE9$;32|!?AYupEO4IXO{wFE<8~#qZ^fM)A|uTdsMH$m z+y=pr{j>Z90~irFOm3F$QeekREO$q3wU02Exh{MHFK0jXy_KhIhaasOjAM+F8~!83 zZ5#{98~k(WW`IzB>o!`Hpt4O@;x7kFR08VrPyUm}zK+nl%;`UR6NB=yu#%p23FUOs z{@`k-PJabTe!u2d6%^{vWr_$Y*Ug~qVny4_+qr0iu#6+Sft_Y6-X6F5yvyKYRGri> zskXPZW?@(SeqxxQ3J01)BCc&2482Lx2rgPckX;%B_MCnZUq=9xCr4qhKjG=FiF6I- zvxp-?0>pph@XfDadB@VH8Ud;FN9&q~k2HN(ruRt~U$8C}A<2;f0Tt|o*t#B?D$Kr! zZM|98CBNnDExKVIf(7w3r8kOFG5Oe-&twQsv*)wC)_wUxzGVpI32pw&SnnwAfkELI zy_zadDjH-1{;cVv|M(5kfdWSBq%EHa8KoF{!GvMqV0LoekS&zS+*M+s)w~{ZIvF9; zCwi0w!2fKY*A-&}gnkRz^WLax$V$ZSm zxnPE8%}B2O>p7Q_tAB2HkO}BBs#gk7zd{7&UXAEv%2|$MVYi%i?neoF^DAjpW}>fA zf_5XMxD5f6zEF?^zhqvqoee2V*Rchjbw6Zy6#l&iRZKcV^i;+&3Y6?$-v9o8H}Ly3 zWB(7B#F&%A>_4JlQ)V_JV-5~Rb{2LH7A96^Mz)_gn9b}*Pi@HlgM|B`zy0LLod4l} zF=XQWF7seHI}Ln%P}yZ=HtQ*%&rzwgdR+7G)P|mb$^U>#IvfjcAUY|p8S8t4X=C;%ILo6n5 z?@pEH{Q^9x46G76SvfiR^v{Y0a>9KBf03&2BjN}Y*?bKiDvUs*ad6&JoE-%Gc@xJd zd|JNSP3pyD=FBoV0I+o&q28&47jkWMWAl5USos%-A4){02oFyj(km(657y!BV@5cK zF(d$}I>!l`XV=j;9ep3+7KG5Vrg5Qn;o?VI4+zY~Sxs;QFwf}#QuC5a$wflfQI;lv z7+Js=Usdd{Ka_}Nz{RIXg5=4-#X7U;)@N`toPzi4r#QPrE&@I;v=ob04lK=cl00rl z6x74k*q-eWMtmAYJg+0#`>VhpG*@9P-)SBrS&o+|~au+XEot zlfnm>>=w;iB>u@sbCUDjMi%Up4T7q!$w+hWE^Wl74^LtvL#wwbSqmfG7Zsm_4Vy=Y zp3HGk(qg9|n5dzq=H1MmGIb(rv}%bi4tKG=g;j*W%b@?g#Jf;mYcdMNcEz}Pm2->QmCcVBXSbzi#l^-s;U(()o)VIlx9B!mc5p>mzcweHI`({ z=sfk5ZlygjDd9vzHdIVU2kR>S6e}priCSmU6EQ0cKFr%(Z_rM%tQ@qJo1xjgr&&`E z5TXd_FC=)jhLqiGTdkTvg zEWEScC-TNeI;>M>+IK9hUpE?Y3Lb@9*yk>V(#||t}6Q~tMLqm;?BR!6>wh{d$y4V(ZTlP6rYW6T?r5X4A z<%-FB_-_8X+YX;p7sCSk5Hl?&)Vn^TyO2kByJ{gGk30~H#jC8>~fT5_}*5MNtIaBuds zwLWcoH@^fq%nQLe6V*olSd(CauBvr$Jr40$zAak+TM^a;KNsSc1(BYo?59c(lT1#- zRNuX?{cL2NWnLN_3v4w#vjM?q;oeI#8`$TAx<2Evd!#rqF|r#nHEo67M&Ue)wXrgw z0iKZKimi?}h$20ik(t!aWqwZbDfWt0Su68aY(E^J1r_fh_&Zj7B)aAxYzu6x7D)N8 z*cM<+j;cfry;+psAU{FoccOlPOFLr%Dk$@l!oS&urBmiXlOu12)Yh`sw6aYsP20aC z9Am+@yAXK3sTjP+Wg&ikxiTbL!ow-IHH#9Bx6Sn+i6qfZ*k`5={Eow4%Wj-bl*okE zpO(eQ9iim^^ySB#N)kjhD!@)4UDv86+z-D*sZ)VWZm^`=$~|96by7;Ci2cnBg`v%uo~n4;l&qGxxFwI&{r6w2zVDaxgJoQUG>7X|2Zy%b<|vbN>ey7F zT$Rs7k*LzoD>b{_+NzCF{qu0+yLPnUoz&9J9ZIg>A}9j6i|k$Cm3vH>mll)fAm8{u za+oH4vqNDKuYPGc?Ap7gLlN3nA4qdpTIEq`;~36w`zr}4+=4v^RT_#C->`7@ZW15I z5C~oO=6*+Mi@z;1KB>E3)3Z%vmMVqnmwP9k|GM6_c5uyh> zjeA|92)c7lEBV}i#eZn;9Co{Xs@{i7T-Tq`UGDEoKZCt}IQ}VP0BW+eK&U-0;%45^ zCm@k-dFU42=l~O-VqY{tc6W@U ze@Vd9nXjO4^CR`FFDQ5j!YCXbpw=Qt6e^T}ilctRs=(i)Qy&4{Jq-4vp@ZIEJ4jT)$MNk1*% z)cDDY-%=NZ(?T;*%1-AdQsqumL@lb0aM!>%BB@1=OtqG7(mMZ9^sUg zgrWVs=OE&h1G;>}#rZQuNOWeSt~40WgOaN(i*E?Vh$l*x5D4Qwitw0hRz3qsdGY*0 z#i+#?1ieT>sE1DtR(vX9~V!)~-B|F&o0nZ{=E zv;00aLJjjnwOBv<6rG0Trl3gDS-O~ITD_Mu22U-%^{`GMquy(1r!-FgtDL<*7(aqo zR+lh&b-)&ZQ%Pkj1#DS6I5X{(jaQQMB&3k#k?P*yH9)p5U6EQ5{J6(Z493941nJJf zoC(z6k18|FC3&`j5}iJw-~a}1o!p2E&K@0w-)5hOo>^pMF4iduTr9hrJ_PT_q~q@G z??}oG)^XSx)?F27(JC{{Vf$KldZ&yX5erhRcvokoi|WADIMy%fBqY)#jNJ`N*vOm6 zX3j5Hu#)_H3`%~jU65SR$=VA(0*_ztG>kJ&-0Td?^=#wdm#&ErwL3~tJKk$a9`IPZ zeu=mInmw`Kz|Z8WfCNY6V1}Y z7Id1#f8d+u!6-Upu=pEAoh~l#ST27Mpm6h$1jsPIn7o%=Nd_veLng#x{khyt{dO61 z?&VlG1BzDxLSAH%lld2aZWc2A!F;5k@V_#-nsOV9Om5aO0Rw@%weSnidFOIS29 zdPNFR1dX=l#c2gCWcd_Bzz5SZ5|f-+Rg}SxGR!|z?GeCh-|y=-d_?^sJlJ@47pO*| zs>!lPIs3+MF67!0AdWw`a46!rg4@tY`-)4}1r~`=w7)4{y0bM4=lw>;@=a^hHked0 zZ4t9*XpUD!>kGdcmGPdEq$rN(-Z@&9d@D$WrTNdr4O4KZZqJ$HT=_3ZV(IK;lJo!V zo8_)dU;gjp-ptsDm6eT&$&mRcuxB%3{}EIEyuAE0&zM-**nXIbjBF-MtcGT0CTu3g zhHRXyoSaO|EXF@dFe5`QLq=wk|9*LKhX2pxUhcnNURG>%RWx!T9JXE2Bd^BbD8ZIO zL6WgSAr=Ofq7@t@nN{^1coyIKnutXqA%WQ?gdj>Pw)(2Etx`nsn3S>QJ!n@1V+Dts zl(B6Mw;WR)D)K)eBX*iht+er76oRg@d|OYpx$LiBcHRZPcfY%*{nwH^T)%jax)#Q! zst@$U5~@Uq5qj`&+L=j}^%a|HEzG~Nz{E1NuARqcx(;Z)^s~F2?a{Y;DZRLe+>Rns zh{A=xdM8in_a^-I;p~i{{J{p`Uc2xPH*%|bReak4NOFiZ$&-zkX80iy6|XrQ2r<@* z;wUdA@h4A!I$9mm#INJs1z^W+R5ZAsZAETBqu;k}XFRhWd$i6{^lo~(Y?RR?XV!IK z-i!Y*a+_n-$n38XzmW%!;zPY!st^Cw!`EHArh$bSLA;i+BU{)*gaWVJhc^XNHcy) z12v3|)_vfIuAo2KBXMeo#0p(a-p4hRGn}wwWe+)X8#jWdDU^pT_uq(+)=a@ag>`ob`vrl2J2-YulP4wl=3Kotzy87dcKxr!)sbAg;qFRwC zwx9?0Y_c^8F6dahSh+o$ud?P~-cy*E4JNYoTLWfpQ@bvt?Hylp%C*}%8MmzMSod)0fM=WH(J+$m+Q zBqKd#hZ2(58<+`d;~qx(%w--n zcky~(2kq3Z4{5+UT~F6drP#)6cQ)n~_6wjN7)_neL;e-EfoN%Zm z-mEbPdO%rc?R23_b)a`l_>peW_GDxDt&f-Xn5+KMvt@ zySrwBOHupS*Y@XvkMOdkt@cYxheM50#D1=2Ty$Ppw}KeP;Jom2 zx7fH3Ij7qVsi^E-4AByWY#6Pi>1&>_QyeMr)@AgH^jd($7nKc8Q6RT{^UCJ*v?4h6 zDopxrNsCu8?{^H4*zs=(=Wq?ZuhycuYW>0U!$ZL*ocp9?bu*dPp<%mi$tRVcY@yvq zO+vk4(&eM^2E$c5PxIB!WXfI=o_g4IwI`sy-g@UmY|$4yYv;IAxw)luuJ^YYZcd}H zLy*wJcNaOxySBxXc5~Z$nW48FP3i-L*34_kBBsadHUGu%Jism;>j*W zjQd*#IeB{#%NnY3!?ui)euSU^))eMBxpNHGzo1^8dCS%0TwC@$n`(|wYdF(H%b~3+ z*YS4M4=wGt2=tg|I*uUbD_T`}j)3=+8w(k$InxT0FJ9848OpC*N&M22o zUCygu&ZLjg6?kTno@n(X{$VKmsOdVgbK|CV)5%w5%l($DLLPghR(|LS76eF1@WDw+ zm((%>vRLbwPOY+C-D0BPcD)@Jw<)k(XH_zx+sURv`ADgKagFAfjCkUnszI?%#U|PF zVM|&Lx-Va7mE|F$R#z|cF@MQaH8YYOHQ!;SB$%33ocKEMgk!`7z82)(sCNFaYtNMz z(S2s2E7cz!MmZRt7<<^YqEER+w*Kt%Ea|?VS6zQiW_7d6MdhNw57%4vp%&+lKW1t= z$XJJFq7S?BsDVN9p{}!9_HaaI^83x8;wP%XCS$E8mK;szkcn-{WR=s-E9Y4k5-$4- zKXhQ4TD)0TX;O5#t19!vXqS#yZ~EDQsr73b%@O-CTe)4=P)}!g>z+?lbO*#=T|$&U z5q^&|DO}KS#U_?1g;mxHW60iliw*Y$?Z59~eS0|em|oDpq}QF!*IR zL?>Qu?{tis*}3_dop+bYKYw{?ZJU?4dZ0tz@l)QM+xe4u@0E+QZGZ#d7=0Luvbi7@_+@ z)L6A|KY48HujVe^Rp%1Wer;%XA2RRQ&%NUopL8+&fKu0DQVldq*HR?^p=haU9^#Qr z<&l)Up!wv&l zM3g6+*4#VpHN|wbZNXH_C^GNkv(YS|N|l2(Q+;b;sRK+6b@3(Ho-YmlIjo zc_1KJ`YTKS_@w%C#IUy4(9i@!cK)nK0nei&sjuA@7i@m^?$CN=a_pq@@yUw0Wkms@(^;N-TlFbh~~Mh8z;mp9yBA2CK2wRZuu|0E=IZZZS}fd&Q@hb15Z9%i`Sjh`Ng>z^O+!0ob@S*Twb*4&g#(pxZ}u4zSV0W- z>CuHu05NWFO5#!97#FvH*HIe+kAEvVdZs0-WyGdl2;ywbUXV5w)jr57$~MP zWyMgrA0dp6l(R!3EXH0!)f_WcPetzyJD9ajIXu%SFkECcE4!w4kNuv@^h!CMl=>cP zl!^$Ct!Bg>mlH7fS+0F2Ph3&G@$RBT z4VRwsSUPaThe7*tm(3VsEZ=3`!07Th4wm4|RrYJO`-?;Ao6;pLtk#C8pSKpPe}1>v za8nuI{$3R5w%K35{YLRaO?>$Ll*ZvD@v$)x*H1=g)qB^&)99ZEWxQ;Yov=OZ)t_wU zqwz|$F8sE!x6p`Ji)70yO$|*am1jFY{m_L!UiBq$<*`nl%MqOM%RLY-|EwC%Zyxv9 z)m52g_!^Q->{U$)>)G*c-&w`ts|W4gzr66-u^De|#M?J z^=wUZUvK8+%z=wi3~4!}W*NOpHa7)~$Nij6Z(!RR?lz6$9DS6(EGK4XwOr&Wp{k@% zLuD&*EFEDqq}69b4$Rmd#N>awYIU=%N14WwjG2J-Ji9|Ds$yQ zmzICe2`?#8oq*8tCcOicgRT{#-+2U!e(ZcHlo0g&pgP??Z-iocGgPg`y2Z0^aP&A|bJp$C0ypAt=AdIq^IpGJ(XmwLEgo@p*1XjdLeSV%J1^|_K;ce6m} zh_q*b-jUoVjklDXG6Q;bCfud>?mLt8gp0nk)Z^G=FYENz#AV~0V*HwV#l!}MVVwWj z63NE6>IUOBs{vhtmR(lcQSC#QL(9AM;zRw43(!xH6VD1IjyKp}i4k`g2qB$lFoEP( zXWyMYE4*%VLA>F~=l~-I{XS&v0dde+rOE!JDR;x9$oyqalk3-yC$VC9T!}9aw#jx4 z7Jo24@6lL1SUeeJ8L%?_WI~o@*Wi}@gsM8GA^p+N;Li8YcP+FHa$|DaD=K3iWz!kv zJe03nwIa@7yRXNQcP*B+O9LEJ?~7@cetd*9h>-NV=w8ibhU6H(_dwa~f>6{rpG@BJ z%P&XOUa6f;s%1~W8N=Ia-I7F<5|{YOh|zQ}D$E($$b-+MXZu(toZuAGcHfu5YXmuM8eOHfvb(3A#c7+_HqPC9&&HL_vE!+bG?t8Jxq8f zp-amAlG6?%_0u`+4SSjESEd8P@7(`5$c%CE+8_S@}D zI?T?86Y6<~>56;0+LEfGWP6NWGks#gB(N5L+P;#X_CZnS(jkxP5~x{+k#BKUtiZ^C zLY?Vh1y7k6>O!FVtN?oa=3Qfr1Ug5y2J^iOJY~GPdfyKRp)~0nxvM`3DpZ>3#~JU2 z6Bq^t*^&MX4VC8gUB1N;&g1M&R$uO8YDz4EcJAfy*c{D|O2N&B3eKI`e2<2GqVw#y zTD^HqQ1{}eOJc8XajMvBN{&DEatP}ahFoGPo)YJft1l2$d@7!lc3hs$R3_6#BjmI6 zFlNR6j&fb&WYjRhgl<0X8+&bG%SJ(^9R^+cNiafi`}QX_iMb{*a&OXv>DyDW79Lx& zmFA1~)M?YLc^ChU_WL|0%s1)uwtK1xQn)I~fG1ab)0)41!5a+UT zq?2Aj`bxb%zGu*`tTD44!6TH-UAkX2G)z0!ulDisy2U*(j;kOHJOSPu5E%DIF`oN3=T;H3;Im>4A=G*eS`tJ3NxC5G^Y4?P0K zBV-7h+4DKx{Bewss_V)^-R7xau4S8m#7JL*VZB=;141Gm>^3jmJvcl_X6kXd&q{7p zb26KrU6^qrPK&*Wu7KmhHCT%SQbz}LM45`~uFNaogX&N!uF-4zv(qKsEE9(5(1t7S znYw-=iMcxypGVk>^A3$}d1@M=nQy0Xy}Qfac16k{VMCq?mfAZw63`6I`9Q4muGb95 zdYa)WImfJ}GclzoPYvUyX$L zv4ew&RMuhPxw8Fd1I`ffD(gO)JvHJRkGx^&$!9R` zU_1HqY|&<_bz+Xhn>*-_XN~~E<^6|7m)DywP)8!PzsacI#mJJ5Z+os6!S*^BoT<|m z9K-i5J8w+R*6(dQXxF#U;&jvLx#WV--aLaNxQa|-J)hoz#l?r3W~Xew#qVPde{(8O zVXR-qNW+DL&E%x%s4te$Ml$J8p`Wuy2yb3w(R;rVe2bp=I+vB>EhO`?ZRhZ+{*JGF z)R>ddL#HejV37Q8Ms05Mae|z0I6Qj}#}c@v&kQJAAf#ofhi#l|!-eTgEq-*|95Ae_ zvi#<3%GwY}-)~9X-}YF3cRqx-B(gM*tK@56Y{`=T5l)-f+8}PBYE_7=x!nbw>T@Y5 z*+tKc_UL{0lP7G4KlK+wr8*vq_^f_&RZ1VPj%RKODot|~N>bqTXWD)6NrH@8)Pm)u zN${4qelmTH#awE-)0k0**|R+N%ueQm(Y5_^%~DT%u9FxfCv9sR^=7=G0=)?jY~qfH zX};>)vNZ`Tb5S)6;=HlL_4+a6hZ2Fgs4KYTwx5R5WG#&o-kFEw0mG(|mVS~-=%ALae7 zQ0MC>MB&u~GB#7jMA2ASjf5^!TP3?{J^x(RF(1PcWPYd8g#fj5$BE}-rY=7Z`ksLb zt-=gdnFk8Q*1@rCJC$L;!k=%yZ3}rz5kC z>F41uk%g&s74M=8=uG7su16kcr2DMAO27*=MA}h|>wV={6gRRF^8D;r#r-aZ6BAWOhR#6BlkSDiYG@-04WIDgWu zgt5U2Pgp}~MKEvQiO7DdX+lPEswaMpv;EjoT~crq)1LP7oHO0Lt4?SqZYmufxTu;wtRC5b+o#uM_yv3;dyo$~# zcPY;qQ_}kosdMGTSfz6e332ZtE1XgW!&2ALYGpqwoK$;x%uaLgp zY*fE8i9izFMO;soRmL1}>&@37D{CBTHhcON8G>oE-`5h^|2OR{xNZ_8dugt#LHQ0)H=oUiRwD%&HBWp)3Es z6~a_=-GJ4!bx}<^F+=$kM^orzDYKwO)$vTbeR<5LPFeeG#u;d#|7b(bd*+&wkIdhZ z6*p_*NA@pg>gW6B!^)j_&B>lWtII?UbZg^_}lvx z>OZvD(=VasW00;wpgue6lh(19adN6m+M#L|y)7A@PJvlErfU2t$>$1fzbDYCaaWZW z-dbkLI-e76$S=e+I`woZ0fL_J)ly!EWboL3!!Ej$_@1Usse%`aYZp2KYvSo*U zr=!^S)jI0u;DAft6@D#ND^Wv#`tQXO3_-@hF`$(o97#kG2}C%!vO#MfL&BhO6fy!% zCgNb=(lQ=Pz(6TvG@1y`fyj6Ql1L<>!MBkASuC~x?_w$Le~Z@cb@^a>{hFCv+Eg`L zu99*!Qc4cx#7l-me)PyEKjvr-()Zzk1t3=HmS$9K0f4ezLFn? zi&;zNPxh%-`MBU)v{AINyZ9U|jJ(o!s;z}iGVT#$Byc7A7ZIBGOZjn?^zY-U@#6{< z{m|c37gDFzq_Cy-tir3y*t*!dZeDnnO>w2Tq!DH*H+s_5Z?`^pdDg(>WnF?>*nX$N zVgg5?&7$0-k6Q|{{VSX35Xkp9tR6a9Zn5gcZcb2IlJ|QxK)AG8bSLQj&S$FUWrR?$pt2E!>Xw zocAg2cjc8OVPyN~8E^V~8O>>RcZ>p^g;qZ3$lPNWAatcMTJ5gtT^y?C z^6y{Tf*1J)8YDCHd&V}E&OJ`bc=V3$n$O~Xv65HIXD<&OpM9`w!rc$sh0(8+XP?b} zl9qC&{o8Txr#`}{xVc4kdJ$@ngF|n0ocgjto7WrfzKMzqz1LY6e+bm1)JF%xo=-!K zB}0pA+$-KCq-PYAkGZbRm+{_oJ>kJDf_Y@~rcqROF(O@jzYKkOk8^9!ajUwU*8ZDA zavi!`YD|pRZ)Vi4M#E-yuB@-yTV_g$wJE3NO&aT+lfkuB^sBhG8^1NWFRys&Dw~qr zIll{DwzX^iFZhZU&bXXX3a~m_h?yLvXH+iUQ#gPsr(1W`&{S| z73hmK6ZSz;`wegFcE}G2npwMXk3yR1Hl-or4==AexrFhI8szK`igrk!s%(57J4#n| zHHs8Y)n>FoTcs{cIa*C@ME1Phd5e~xK&<0#(N)<7S>3oGQ2u;&eX-{zK35SG%6a&| z88XP;BX39}5~XkZ(@( zTVy-ST~b8~fVe`!axtzK$_I49=My$tDiua*Q=0#be>MA*u8?Wf* z{XFb)7@|+6nS1gZE~gY<#Y6^DbWM%mCwq>2UV>@M0;=2lCuOwv`QR+igyI zk95-s>q1S(l)snnu|g0G45?GMPK;ZL+RKj)(Vr3FGpi@04WoQ7j`0SCR5sYDTr6v9 zYh)%FYE?3NZ}ALN)!!)hvL8_NKAf6jGTzS|tg+s=hq+Yu^hd&%zUDbdnznbsQg#bt z{du9l+jVpzW03l!nVV-m=WP?t6hlU;_jp!y`ORhIJpQ>S;m}-j)eC=S>Hs}4reRUy zc(|I4eZO=`SN|R2L8SFiVVku8%(f9#s~4~$??Lkjr(%vm zf}#D~_TF=+OM;vH^2`|q%S5|N+_&&nNpXIOKgt-B&2RJZPcG@B6LZ@dpD(r^Wp}eW ztM4H!_CDONAWt>#(p1**t-^P5qxuKr7 z_*w#~v}XAt#>KZIvTV0B_O9@QAGm%mU5Yf1WbIQ|Y;mdRRXu4-zdN=S&2>FX zW=$W=Z|e)howztA6#ZlLXF&V;CYM(?BPb$@{e8SDQESgJCTYI2`lDVh-1FGC4*$88d-f0`#}Ffxabo+`lWy~=tb-NLOIt;kqDrWl$r;~$JZ>L? zXj=d9XQ9`H6sLLnJ+9NkLW)*L<3%Fh^Jqd`)SAD(=(^Ynk%a`yy>*CgmWwExd;I?0 zYo5JnkOplzfA_Hf>mSw^#x|rd4P}t1OI+`qW28$f#M4D#`?*|d~8x}|()KR`$dd~zLf(~tPJ21U)Bbv^XJ!rZ!PtfVwntTEJfrUhj2Hg@0VinpD z`mFmjY)I{+)Mm7eZ6(#kOXg`K7Ti3 z#GYs30xRugcq_$M=S7Rmq`d_)6@JvCYzJz4c`VK1QR{8P3sn8v1*sy5o}zlrj? zK8PV#7dtpm#{;2#*3ZNV8N-?joQA^SzM!_JUz_Q_!@jAv@aRQ0 z7cy(+k`x5fcgTZn`5L;>HcGKjb>i(XXG>?XiOiAWO9$%S8S~H|g+NEFAU&QpJSU|& zg7WG^${?F-3v~F8S2{mNJaQ_2B2u<}|Fm4B=;)qcZXt97i=_x6Lqh!06+7pkSC<7R zl5?{TWO_QIES^P*7CWUYGsaLkf-|xXBz_ikF$o-G;^PQrPT|C@owg~HTM}Z-VQ@Lj zC}MnfkSVuoK6di^KFFrZo#tViYXy4^TFV52wlR&Kf)n`yy-`S1w4=|}gS7{Pj%5_8 zm%m_e>Gg6EhNP-ZxItt&8(x?g^mSuIQVouHd^;9drsQm8xf-28Rjos)XFiL)F-Y~f zU)}lmr?=6=EaMkMZ|mC={C#Q~8plsECHCI1-`UDFLnViDNjiBaL31?XD9%B1&Iyk- zIWF{t-uh7}Yo9XdkX{gOmGD^t(>f9?Zq>kA9%aml>k>{dCO(urXCZdf%I#Sfmh5Zx z75y$`<-3l$Ls;cv*dyWYYn^v&s1%b98})Pddd9yxeUagbzgQ$7IlHLaROaKN6TH-G zc&BG&Tv0$n$k+2tQ9%W3Illol8{p?wJg;up8kmJ+-Ep|#-S}!> zvHw%DA|#IX7R-2g??KV2R^S>xH%G))$}9m6)Ue$U~D8Pb)_A}I>>1Im{_$Az`Wk+Z^zfT-emx5h<3v|qWJuIGmp4*;PP54@!J}ax@y1f>0liFB$MmA` zbPdUh0{m%(^G2MIuZxz9HhhrIr3-Ph`}^~zAp;^o#>u3;KXeV#AD!KLm-apGlr}_} zwIS!q&X?_Cb5XjqPyuCSL@4+4>oP+?n3-WdcPiD(^qygf=Xa^kk_x@4=M`fN>vWGQ z$Ugg!riJ}jAyIl{S{b5(d)*arHL418r`5wZ{^Wyjvp<$GuO^#t7gjm@MTha18WNDZroYZu3YC> z#Xo)c!8Om+>kWfiHKWHvZI!_MIsHrN2l`gdkpsg%OqIj%R+^_cAg}0(S?51G9-KNc za4?8SmvlA(Vv{Q8($2HlV$n?ZG|`L;3lZ@&+$wKr(r0dO9u zvRHH4W`xUovqr_TI2*~;qb8K5s88Pz(ZXfw!)$&y=yU%3h-C7x%}QqF$btOn6M&pm zSFp#XpZVkJtm=d|chbupsg?7p8=dPw#t3#ec8lVbanNy3|OXe_lSsj$&kj;IJ z30`w;o8npy)S%cUQSd5h)BA$Y89L^e-h-sAKc6o|K#rfbIXGhN#l1i8cn>*d+syp< zZf9lV+XIQl8+F3(?3K=tBb=6w7&atN*h{3YRmZsmb=`%p&7b$0sT`?cx^AVM*Pp@Q`^lsSl-6U_%H zp4X6Luy7K)8qLO*{0%)J9MPkTYc`$p7hBs-y;h3o_>p$AReHI_A91ffFd$}Pg|l62 zQ6<0P+I}6!EjfX2#STlK5)VA(mW=DQJD2`|buMD}?!$LQ$2}8OV0(l&z73HN((zFp zVTb*`7qquW8p6ERm2@_pe_aNuIcL!S_c9QUB;nvt90jcC$T&O(Pr`z}R#*x+BtyeV zFfj5v_x5v)!iR87F;CV>DC6Gf= zOi^U5VExEYNWJ|tob_YDkmbtS`m6^t_aL=^+Ry-{wm$FaJQ=%X-{O}aIIM5=b6E07 zi)(-Bvwq#RPpM^x+NrL$>3(ClAttL{{qJGJ;NYeffs6wOf&?rIL&6ZCaIgqQVTfov z;55XL0VN{fIfRqI#|R=IJVZlD1W*PEg92foQU4$3;r}ngZl|g%d^|U7S+lxsP#01= zU*D&4i!xxvy|X&hn6Ph;cA1Ey%_E+lJLJq%(qA|E|7<_X1cpsVFUKGU zW`WkeRTP5lO?9Qs3GGGI!PkF&1<}#|h60Q|I4FjQBvKGW92hR3 zJeh#Q5GXj%N|p$N5s?TI28KmL!M*_o2M1)^wAue1(PN7ko(!Jd3BG1YHqDR1<#)S1Cfay0)fJ?2(Wbl>Q{+K0)+%x;NmcFBpFXaAVBvC0-6X1dmTgy znm|Dy@wC1bFa#Wd#lk^HHxiCa{HJBx?}%ZwJR|P`8Viu(uVX|oKp-#-nF2?`!AUge z6$b?@nKX4Gffg7Tz>|c)pa=*klt3h4uoNN*ibVlw0;Qusp%@&B03(ozf0L^DjJOz0 zezJcc1~Nh*;7TD84aR~4C){vw-c93e0*}POpcE1yjR1I1a1{}pb;HqE7z&91v`r+? z{Fi`&VxT~s|0-1qzvn9vfM)my;@lnxgn%cba5%tF0mWlsP#hYI#Do5KND>SPkpSEW z1p%xCw7$ats{(sSz_$Po0%$6b6apEv{DA#!jQpt?NPU17_y;1mNe>~C5Mau1WF!zS znKnz{cpnNZ7ci6pHh3fji9n!<7|_@liJ$;41uPeffMY78W`pZgyHt`3dGU&SuMd1-}JOTzdS20i$7KKB@;h?=BP)-;E zP;ilPWDF9D0rmsK;Epa%E-;;m>G=o17-+|3S6qpbU;0r^586zQ3L>!6&#X@OasDVmh0R1em2*73nx>l25 zBsBOg9F9hja7Y4)1VtbyguhAE-%8*13P6kd1JUaMKm_`LK!HY#WGog+gd=G-Mgli+ zp=1;qjz)vV*&wnJi8v4m5o9DBiNb^ajo|h!s3t*TafpAmI)9hyo@a}3B>=7W55!B{ z5D0LcU?&wxLIWR0pkRS>1CK?ZfO(KmzzNafC*y4KR`Cbo z^TRX`KtTZQAz}zX4`2jf_Amqjil-p3BrF+58!812{5ArC0&xU{EjSzs0tuK~G64g; z9p-PEBb;9)+78h60x$ZjR6E3h2fzaNh#{fLFen0qDgqWs>+}iy3mkYo9FQ)73(0Wfah*nxh6Pz9_B3&J-V3W5n>YyR69=^kG8PX}lgM zG#dCAklT_`;G1C1h)58e;3#kwf(K3qK>!&r9OxM^S{M}lH>oDJYuZEsw5dN3mz03b z03m|sgZKkSB9XvM5GY`D2#~`7*N2AEQV=K>jRQUp2cj_y1GpB^FpvvjVH6zb4Di>8 zeES~LP!T{o&-A-gSJWU75($pSU_cgy1l|P?(13>lhK|C(frkMHI7nbVcpw21=tF=Y zh@;CD%Qkaq#&#p3=+qJQ@Q#iozEmH-+QMEqYh2dWS9 z91xGuAe^1)tQ&o0no%iivH^uy{*7JqQI3Z;4X1MdLX9(uT26^1}Q2? zE^r769tapHD(EwT1&ldhJqE;$25B+9s=Xb`vZ~Q2Y5MXi*oeV+i1a8$cmL z2_!fah9U!@!@=qV2b??%4)UVEBbthJ)zZlBX~g%xj?v&6h>tL^hNL+)7#xIPkk{Y< zUn-0M%2Pp-4#Fm$Oh#Zq2`Y#xL^1|W0n1I$7mt=0fY6B|{M9;WM6EP3RT{DCuZW~5 z5Uu_N+ScFgkw!~LqmHA|$o+~qa~oLczd%y=J0gwOgvLEW<01JKkv|>i>c7Bp@;f4} z$v3SLH?1l6uZV%UKv#kK|6f~v|Bgs&n@DSINNaQWD`I*n(5HW)iQ?~wv|cr|eloP) zGQT3G)`86GU+7%(J0k6ZDeV#{?Lz3Uh^DO|c7V9>f4y${J0h(foK~|MHRHd2U-!tuZUV7fj#~UB^JLU()RCyK|1y4evaM>$R7WN-MrrsX4gf{qp%5q>j)maxAOsc)LINRhC=iar!r)j07=^$`FZ`XgCu^9WrgF9)*q_hRdbTUbQ1JFS$LwFqInnE<=8vmG&d|G+H+Z1%qVl!3mQvGqWWKtEw8ax z_xHyJ!r@RD7>WhpK^PnogNNd=cr*rq0YNcfC=>-mBCtpR0t!Gv@K^{G06`(K2mtYq zFnACE2g4)(dhG7sj~ny9KK4S?e;(WWl)jB}E=Q+EQg-iPMa4>%P)N|(4=(UWB^T*O zwNADPPTXUft)VJEAATn6((U9_-Yltj`uJhJ+NsGXcj~1Yt=;6B=NSh&E5P=kA6}D@ zwEwUMpmIOq@793AAs7e@jKYFJ5GW9ZfrG(7JPr+k!_jyI1PlQoFbDu1g99K@Ko}N> z!(u>KECz|i;lW@i8jSnP8Y{o8Q2M`G!|%%PD8Lew`|OPGOilQcs8!&CbRYIqrT$iX3e#gwBfX(TF_KWqMzuO89 zM*si_AP|Q`0Rb2Q21m3R7D==N2nxWW@mLfJ3Iw5HPz(-^z$5;QB>)%;LSq0x05OaZ z;J<8jmGoam8vdVc^?l)Aq4TG$!h_c7PekfM%2ZbN94Yt*$IUt{YZ&k!u!IuRL217m z=`_29F_-C8oA(_f2VjXO!i^$$kMrQP>=u|}-B_n3&(_-H{z_r_Bf-XR(N@R#KkhVT z<69F8$+R*OL}(4a)p?k@yvEu!o`=s9*xg-%3DcW(rVl%0A8eMO4(kk{PsChp6l#(` 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

{@U4myW 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 edCn>z{u?K3c2-QsJN_QyI4bp57DQW2jrMo-! z{`Nb^_Z-jr{r5Y2hIwXS*faOK*SglauC>xug|N0Jt*Qz9AYA}}h6alo`rJL;#&Wq5a^IZ07^xPOKv~XMFse7Isu4}lJlxs~v@%X=v zjmqZ>`}5d13;+!PVe#bS0FXcc1cE@KaO7j+0a!c?4~F8AI1mbt$D@&GI2aFxBLM&m z6oUZcK_Dm!3IqRr?5^L(js0JbJs0_(V`r~0MAFPyZA@@NclWY1Y|hUWk@R+hf$wkW zqfTo))R;Y;Kjt_UTCdJ&`Q9GLh32@~Ei`@!^`bjeB#G&2{p5Q}uqKZ97iJszj^PPK z%f|it7ye<7u%p|b|Fj1N3I*WtC=37&g2FI(2nvnGz%c+k1dKt$$fw5Q5kLeMjKf1A zcmxQI#bN+JJQM*&!*O5)1`7Y%9!tONQ1-vt!{@?pKfsdxKq7`j{rtjAhuM(f)SRoE zM|JKa_~S=STG)ehl8h+nn@L04X;}kF8-{zLx49?I=-cWxq9kp=FjpBtCPAGU+2+ zPg@ip?v9PI!fy!>6^&PB$GrH$D%xh_#(jh4&qM(uH00tQ#Le#c-ix1wK0G z%&e$r!Nc4FYt^5QNnbcMTCQ&}5q37T>-1qI^6hUA@V1;MR+a1a4W{SVjc58WTrssC zRof!TlWyp1*J8?;HBR-%J|se{LoOWROmx9zG$0Z`oYaFKSy09aUm~ zWRov=kWW}M@mATmw`ZKK{TC)5rrV0W9!wz}qoYjWRf-LXtM;Jp&Yl_Z({WQ{zHL#R zMsrMV$+_;QxxP=ldu#daDa&G(q!I~gE~qV|{fvAU@RSKPgNVO9AKX8+_Xd>R#$z@H=boEon$sHpQ(X zUizk)WcB4m9WszegcH|+(1I7Ihb!ulIb2sidmhTrS86Qx{WC!nh?)`pyhs!T0mi@~ zNC*;+gW_>OG=N-3q0k682o8rq-~a>~O0FTWKyrFTf`M>25P-)21$N}R0}Y2j|6Nq& z{=P`>|24##F8mQU zXX<2pIC!k%Se7tvk~SYa5 za_^VtoE!Xzt|b=J=}=LlCfT~YeHrW1?NranoTxZGnZC%}wdnkr?Bb_KnTOBW^Ijj( zyPb<{%y~y*ylz>t6=pWTI_psP_Ln|KT+K!z?D^hPVsq`!DaF#pR}cNRA+_19gxM`k z#MZ%gxw1e$PB);}^l8iG9X;DX{nMqMl8#|!*`i=9m3_YMfeeZ`D0IT8&KE_9ZD;;C z)~;7sY~?T~BNk&L$-w@PT$ar{aqrK22jNg)I21?@M?4G-fFU6mG!h8KV#(2qAjdWk zi69qKP&6C^BG-~A92|{?0}i7B@x?6HjDN! zhjdmd+8umJ>r8nb!fv?!jZ4S!JXJ^RUIHn_JiJV1?x%6m*JWn+G?AlkKWwKB6A6xq zsvd;VUG8Ev!Dg@MR*f*rl}+T=Hx;TU!@opSBt%bt&1Q}!)gLs&0VkgZcPP1_v9~7< zN%==OP15>M0{28@bKG^+j;x2HqR&|Q>!0#(tcMamzO6;<(0^Gm{_=#kXM?!wTt~;0 zFT)rhegd6c=M8f6cTTI&w}6kYViOFH6D(#wOEJh%ipXhmX+1*&Y@eAK?SOgpHYWBC zdbFz6_%eApiA6Ice9bIvMSA$Cl1;7tO{vk(D?h%O+1Xf7(uwYNJwIuDT+q^pQrTs= zpGntNLR(!SIu}E`c2v4Vnmtq&a}_&5J1OMD9jwZRT(s|#ylm!Ayl#}5=EcdJ`-i){ zta=&nr@H`4)NeVx)34@SAZ99@})naUc?bBL_=rk5S- zZ1gvZ5C~0K#3x!m*hBW*3-|7lf&cDLuGjBNmlIWeT=xfe#~8sAtch3k@=I;Hu-R96 zWO#iJ)30`Dh3jB#LPaEG6a#fydvr1k<&AG_u^ z&3ebNyz6+!O%uisgRu}p9<#b-{UTB)N%ylw!;l+nrf~LShfZLvkmy0|Hk5Z;{_t%r zvp*9|i`7A_TDaun;dbYV$Q38=qT(cZ@Kx!j1dBXGJ1Vm>byxiEQ^BkO?~N0v#aw>mQ$w)>kF zpbqpT$On>J2W}ZwwHswrD^ou|011njckWNy<0d)2e&al+V*1Z?v^K?<@u#1`$i9Pw zpvX)fK=v^l779dS&|n+}NG>PfKr&)MfG9BbFCanykN^}O3xJ`Z5F8GPCzpg60HfP(oS!oaXxBTWAshxx|l^fO(E?8!R1Ud z;pRFYWxiA81(i&w#>i0brU6d9tg0?jN=kY(*}f|cx6SNAP*P2l7?#7lG0(D*^y5T)eN=GU$xTqW!>AEsS=?8% zem||K#fMQYbCOuS4IREueCV8@8Pn=!-R#TloxT(tum9}x^B60$a?ZB-Y=!NF6Pt?m zl28#qGQb*{9UMf=PrtDYEo%^X@^9ZhcaU(=8 z!rteDpmBn(OPp*Pp?km@f9=73MVJ+gw3(`Mi2wgn=c>(Fy+3^v0|Vg^2pABJ$AQTi zlq^TV!2l2nf&-8fCRuetL&&ld6pAOaXb2F4AUi0YtVjKoPO%^i1cmw=3qyYUrtSag zqiG7i6`8*{HPyxJbsO{-neS}JM~dB9FRNR4s0`HxawgUUyGIzsVqM(~H}_7p_Nyk| zT{Vt(K6!A9Ob$&pfA-x+6JkRw=!9AwWYGAdgN&z;t`q)%>P4VT&5lI50Dv!-e| z4(AWco^`yU-`1LR8AI|6!H$f7CKgXMo}|}Wis^jeOT6@OaH#sJM4U@oahRlt_}M26 z)``87TX^GTGvySy<X+3w=enFJsES;u8Ah>^agy0>M-XM zE3vR@?T!BCj+;<2iY)u3PL)Xhl1c^FMJtcwGsOYhU0imE!_`GU4yjQRul-S+D2o45 z0p@7GsED+vIG^wB-{#6#SwgA33a}p)w6o7unu1!KDRF2Xtfe*B>g`%cY7#EoITk*n zQf1G)vO!B~Es-8}G84wUwC_ERVkK(Sx;~mUcp9GGdum_j*i2{H#3a`vlCf1USsh+g@YrTl-G|4XHHyc45!VUJu86 z?E6ioaXh6}Y#BzYu{nF20Y??GE_5>e%bZnXS#uVYk7oFtA6Ip9Yc8;jj_Sv|)9%2> z^cQrc%(B$-y%*+uK?#b3*h3lj)2BTrd~ze>y;J5sqI;wE+yjRdHT2ZmK~9EH5AOX+ zh7yvb{7k-)i(4?*raQ$r--$!bt-q%%QpIh}?HP>O}M{67L!v4@>x&UbkNnMRMV!cgN7( zqe25ld!*c-WoyZ=)BTmZTZ73&;Z@xc)tJ-z`Cx=2u&=7MooMJ~hi~5AC~2dU>$?%< z?p|6hEIs4WU=dbkb6#EdtUZw?{yRZi&nxh4>YDxAJD7J360q8>p3Kcu4g5aA4xv8w zeXlDbb>4Qou1a9Oelm@{DFadT{Bw?|Km3?0=5%_U>&zfGP}=M4iG99T-sBuwZ@*Ty z4{!tfeXvJtGgD)3Uq-}4pt99TErMr|ux4qM<#o`u(h!{=1$JB z8W*RnD>p@2bcy0dZawyOTvg+^%B1t1ryL(fyb6~|>lSItrDoZubl03C+?V$w75BWk zcIUkWB-Nv0T~$h2?9Cf1zl>l*25H#0Z4@>|JgSJ#tG8xKm6DvQS{-GSV=JQXxwx7P z7Ef8ET^z+{pC-f|WNr15M1qB9xpL+Y)eq3dSu4UFFy#=|<(|(mDmmc2SC@284M`=O zKR)O_+I}VKG5~S7rvd!&!8a+G=AqX)wj$7zQTua0F8h~%=7~2pFFsP|(XkUP7Jg3T}$fD z(;4HC3>>@kkD8eOjUDpO2!Vo;cr=Jim2nud90)^@Qvni($HPEiJOD^$Nhlx`2gJbP zPz(-4P5@xCUJpf)OBpO0jRgR(h`%F*{oKE5;?w^%Lh$%MYT}6_n$Ei8ojtBE#fBA2 zJs0t>s(C&dv)8=yv$uJXV2c#L<>a}vx*K<-uX>vJd^zy@lu*cQ^Bl`xl~aW{`;>JO{Ly$sscH4Iv6EGA{E z8b*@ZM3v?*j;S(MAhYweMI7a7WL-wOMA)`nn0RU)j2G&j3La!0?G9IF;h7!E#&l)VX3C%mJUUSJrm(?xBYwU(B6#33GU= zwwu>Pr$O$htNz8~v#E*K*|4-mUAaH!X5m)HEfF#%!hW2PI|{l!YmwMOcIMW>6kRs7dfPIlD|PpVU8dgI-j zw{(jN&-^3gR`!s0{tP)dx$43~U@$CM8AfBtbr%c(z=6Oh7ywFEfx!@RK$0^M7(#9a zlDR4rfd`X2MGyeFS%k!pIqAPu3;gepd;I(Pf2Id>ybAxJ2WefLq5~r?jfcS-V=0Br z4CCXRAIj{TR4%BN5t5vu-Vkeb_l#GQ7FJ6tj~qG&ctng}0H%GG+3%ex{fn30By)vQ zR%T{f4htB1qta=r%wc!FGMhGfhwf0azEebXa#X_ZV{@JF+08+)Mo((@x5$_iGxI*}X(WGe z?#KoQx%|548?{F*sK3MR!*UW|Dz&C!M2gb&PnoYVgPgSFKM}3?^bIig{t!&G*pite zjW#UuU|lg@duMs_o7*OI8xAJvgj7gRNCJSn?p?()T-#&bHDarmA5FB_@T13EaL3im zN~e>sRl@OxR!W_xr$5P?gChVa_9p4Qs9#%D@M!q0JB}y5r$;3h6T70S`)j93#zMPZ za;FkHRCcVTVvRCE5?01NP@d-{FH;_&mTC4aWZpWd)Sm3CZDms(?;91kz4++BrExdV z$c^9FfH?3WcwYBy@OpvnHe7Iy6h?RiT=N}>sQ&**{_5}&&7V<>$H2&6fKV6&3?lb?fG|9q+&O~-pa?Xa%=Xb3I0E}`0#))mdc*$L zD88=X^PebgT-14fTP!c6Q05sYU>B&fYIjKMBE;YdRX_^M)Vfwpnqu}ldUzNoTGgUpo^B`xBwbQ!Nofo5i`r5 zN%aWHjJ_~14H)uLl&vTUU)+v8AH-8OUh1KK7-IlYny&{+*EK6{m2I6=wx};?7tg3< zZ|fy)q>IhrzFZwgmbQAezT)F7L51l=UF(Q)yWMLiljEjCn43=E%L6G`*5+@P4?ZG= zbuk1HYKLJbMjA2hmflSv7S(ydr8Bj2x%wg+2U&VQ+nat$*8fcLb|8M~<8n#!s+m7! z+8cScIeuSZyFY4d1)&2vRI4Tqwi@T$uqdlN-pm*bSpMK;(#(i=I=5_C^8VIYL7lt= zE01zP8!fRPck6?)v`qI5Rj*O2_9qnA@@UPq9BmsDQw#bofY%`)JqF0*X``l@Sm*o6 zuhb4FY=VzBIiLL$$p-|1+b!91_TTb8HMm@EpHQ~@E2p||;!}SSmqb=$XNKAZwsOrE zsviCiJ74e4r=KV*W#x)cu~9qy8b;n_Huuauv5hd>)~?{XE4FRCMQp6NFf%Jv*rF&Z zN#T09kdojsT&-|dEI;e|G&J78g5^Ndwv&{)XH&B(!?#6xZ?qb;$}SQxrIl2jWXp?h z?Q-s1hkF@Zl{65&x>3;;c&y&Xt+HBc&(6DEo@B6juY!0z|JQU9>+GkxdDE*ft&+oH zm3_jU6K&VdqZI$5M2??HJ7SFjgXm*b`ZQ#0*P7E#gJ1Q|pfDlCu6JYWMa8>j(=mD~ zq{o&)RotrgMsD?n|CcvhaYF`1ABK# z?OwwHO~atZeA=-zR@Ggou;*=l0x^jgXS$xRmTN<}THqF!+EaGOFhHVZ2%Xe~#+~G%#g|Bww&JNJHxUEd9lMp4w?L&t4%JW}!MgyyvA}5Sq2sx3Y%*WQ_ie`x?S(dz}UPtfl_O z_ax->vj*>{eX8%-v5d}=tuIn^AZ6Deh+|@z;Q6096=O()LvyH1o zw{y6bLQ*SYkw8;G^U?=kr>u=IHvS#4KK-uTIr=6SDj({jb97Rake!25=j+M)rl*%2@0_#l8L}aGb|U zaErP}_fS8q)8BaLhOyE2$HfQ}<e@|Twa zi|1V|iVr{ih~hwYNm?i9KDTbVo%}Jgc>;0T3wu~3O>Dpx7A=f6!#ezpPktF&f7ctl z@g=jzc5hcFfhR(3p0=aH>}0v){w{ZVzS>ARzAdXkPMV;=$K<_&gpf7VYe!pRv%|qY z@mi*!>zi+^9@>%YsIeK5(@{BeojEqCIJQ);lZeTZkP^?$)Kf65v(~k+?9TM8`#24} zciUAoD+lR;8)%OBl%wP;>lP?2NU5|a^JTbQX!mXYj4tkst;$DVNH`7jt5fQ0ynlOb8~b-xs=i3FnSLH%S#<;)Tq)Qg@qexb2~NKhy5;6n5-ra%SDnH`%P!p>!){~P`@r@BD<~}k|m9FhK zXKe(?AD6hIv<8LbfI1I4MNfp37DAzSr48JY+H_$_uk9piWAqQrR*MuFiqKUhI}4tU zsb7ZE4384oK8N>1nQo_-NL#svM;X-F>CO7nRn$&FdfQi`^0JVz?q6jO^uoUvUpsyq-U2 zchdWi7Y{1VA_j+Chc7YaG0v>qyRK&-^s%6>tWNpbP>Rl(VK})Ht?bFr~74b+SCp z7G<0vfYKKWRbO#l*j=#bXOzZ=3!Ftpujy1YFYyMZZzFz~gan0*w4G%!5B_DCQUOhN zlTBqcQdB^xlnYKtPJ(GeE-@bWFmSPho3XjqW6z}_Oeon_=?&KS#eMj;&okGhQPDe# zdTs2viszrQh(5ShOu?IVZf^|GIY!I!Slr~?miCNv-W*FOwUX17;|a&FE8YBWBO-g_ zReQk~tfF7688!5yNl$!FBmwj@jk_*&J z87`C#`TjbZX9Z95HsjZHeu}&inq~EI>m$~no>8Tk7dwQ zfy(z~e#R?Mj6e8B{*6W-7w-|$cVjIC%FJdYog2U;uaiz6fM)ttqk4dL zW@1U*vil}rZ*?(!lE&@H)O|HoB#J9GlW6b9R5PW(4=K4Hjj8{d9PxIHwdT!cR{HAV z=ohdy#qtjOSleA0rzRV_F1ChmulYOzC;h06n^k1-7xG(ARo(mq&j}9&mxSMK`8JhP zMp{>w8S$?&H=I12|6z|dR=79QlFS`!vZ2&1tYm&1U&7MID|EPIdF7|^e^2uQt{55o z2~gxY8yJ)m0&?4DbkfT%Sw*PzZY)o<+?*T5db%IWCw zY5np9yuOA$@ZGEWB9n@8hragDm&TY`ZX6f!b#lB`PBS%Veeg&v*)%?7Y42wPZH#7t zX&NsmBe}|z@tx6{^;)!KJ1Hat*4>UKJ8H5X`~wyBB;< znqG0(Sda{&8S}4ktHkcuDqEZ7fTPWrihm%(zs~v`Nd7p0 zk>+)@uG2cZPQFh(SQZTw)0}kXXUr*Y6>!z`ul)Aj%dM%QZKx`ipNp{XULw^leTP#l zE#|`YDfKZlsS&3=dE|J`z2(;CJ6#7RCtg-B^A0V@kMHfZX|_Lpl~hPAz7;Ja)!h=Z zjtx&72O0iqphW!o{9^ak%UYvVu41}d(~a{g=PsAi2XkC!mV*J0weW?Z-71o?r{&_f zVBIH7rGm6vUmklYkA5kl4U?TkHh<}?@L*Pb3V⋙hffI8A9#N#;jCY#)#qjamhi} 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 np.ndarray: + bits = np.unpackbits(FIXTURES[key]) + size = math.isqrt(len(bits)) + return bits[:size * size].reshape(size, size).astype(bool) + + +def render(matrix: np.ndarray, box: int = 6, border: int = 4) -> np.ndarray: + img = np.repeat(np.repeat(np.pad(matrix, border), box, axis=0), box, axis=1) + return np.where(img, 0, 255).astype(np.uint8) + + +def make(data: str, version: int | None = None, level: int = 0, box: int = 6, border: int = 4): + matrix = fixture(hashlib.sha256(f"{version}:{level}:{data}".encode()).hexdigest()) + return matrix, render(matrix, box, border) + + +def warp(img: np.ndarray, H: np.ndarray) -> np.ndarray: + """Bilinear resampling through the output -> input homography H, white outside the image.""" + h, w = img.shape + rows, cols = np.mgrid[0:h, 0:w] + pts = qr._transform(H, np.column_stack((cols.ravel() + 0.5, rows.ravel() + 0.5))) - 0.5 + x0, y0 = np.floor(pts[:, 0]).astype(int), np.floor(pts[:, 1]).astype(int) + fx, fy = pts[:, 0] - x0, pts[:, 1] - y0 + padded = np.pad(img.astype(float), 1, constant_values=255) + + def at(y, x): + return padded[np.clip(y + 1, 0, h + 1), np.clip(x + 1, 0, w + 1)] + + out = at(y0, x0) * (1 - fx) * (1 - fy) + at(y0, x0 + 1) * fx * (1 - fy) + at(y0 + 1, x0) * (1 - fx) * fy + at(y0 + 1, x0 + 1) * fx * fy + return np.clip(out, 0, 255).astype(np.uint8).reshape(h, w) + + +def rotate(img: np.ndarray, angle: float) -> np.ndarray: + h, w = img.shape + t = np.radians(angle) + R = np.array([[np.cos(t), -np.sin(t)], [np.sin(t), np.cos(t)]]) + center = np.array([w / 2, h / 2]) + corners = np.array([(0, 0), (w, 0), (w, h), (0, h)], dtype=float) + return warp(img, qr._perspective((corners - center) @ R.T + center, corners)) + class TestQRCode(OpenpilotTestCase): def test_alignment_positions(self): assert qr._alignment_positions(7) == [6, 22, 38] assert qr._alignment_positions(32) == [6, 34, 60, 86, 112, 138] assert qr._alignment_positions(40) == [6, 30, 58, 86, 114, 142, 170] + + def test_all_versions(self): + for version in range(1, 41): + for level in range(4): + with self.subTest(version=version, level=level): + data = "".join(chr(ord("a") + i % 26) for i in range(version)) + matrix, img = make(data, version, level, box=3) + assert qr.decode_matrix(matrix) == data + assert qr.decode(img) == data + + def test_modes(self): + for data in ["0123456789012345", "HELLO WORLD $1.50", LPA, "こんにちは", "ünïcødé", "mixed 123 ABC xyz"]: + with self.subTest(data=data): + matrix, img = make(data) + assert qr.decode_matrix(matrix) == data + assert qr.decode(img) == data + + def test_error_correction(self): + matrix, _ = make(LPA, level=2) + rng = np.random.default_rng(0) + flipped = matrix.copy() + for r, c in rng.integers(9, matrix.shape[0] - 9, size=(40, 2)): + flipped[r, c] ^= True + assert qr.decode_matrix(flipped) == LPA + + def test_large_modules(self): + for data in ["0123456789012345", "HELLO WORLD $1.50", LPA, "mixed 123 ABC xyz"]: + for box in [16, 20, 24, 32]: + for dark, light in [(0, 255), (60, 200), (140, 250)]: + with self.subTest(data=data, box=box, dark=dark): + _, img = make(data, box=box) + img = np.where(img == 0, dark, light).astype(np.uint8) + assert qr.decode(img) == data + + def test_image_edges(self): + # a code touching the image edge must not lose the rows and columns left over from tiling + matrix, _ = make(LPA) + for size in (200, 203): + with self.subTest(size=size): + img = np.full((size, size), 255, dtype=np.uint8) + code = render(matrix, box=5, border=0) + img[size - code.shape[0]:, size - code.shape[1]:] = code + assert qr.decode(img) == LPA + + def test_eci(self): + # qrcode_eci.npz: packed Segno 1.6.6 matrices, generated with mode='byte', + # eci=True, micro=False and the named encoding. Mixed also includes numeric, + # alphanumeric, and Kanji segments after changing the byte encoding twice. + cases = { + "iso8859-5": "Привет", "utf-16-be": "héllo", "utf-8": "こんにちは", + "shift_jis": "日本語", "cp1251": "Привет", "iso8859-1": "héllo", + "mixed": "hélloПривет日本語123ABC漢字", + } + for encoding, expected in cases.items(): + with self.subTest(encoding=encoding): + matrix = fixture(encoding) + assert qr.decode_matrix(matrix) == expected + assert qr.decode(render(matrix)) == expected + + def test_parse_data(self): + def parse(stream: str) -> str: + stream += '0' * (-len(stream) % 8) + return qr._parse_data([int(stream[i:i + 8], 2) for i in range(0, len(stream), 8)], 1) + + def eci(assignment: str, payload: bytes = b'A') -> str: + return parse('0111' + assignment + '0100' + f'{len(payload):08b}' + ''.join(f'{b:08b}' for b in payload) + '0000') + + # ASCII assignment 170 uses the two-byte ECI representation. + assert eci('1000000010101010') == 'A' + for assignment in ['00001110', '1000001111100111', '110000010000000000000000', '11100000']: + with self.subTest(assignment=assignment), self.assertRaises(qr.QRError): + eci(assignment) + with self.assertRaises(qr.QRError): + eci('00011010', b'\xff') # Invalid UTF-8 must not fall back to Latin-1. + + # out-of-range numeric, alphanumeric, and Kanji values are format errors, not crashes + for stream in ['0001' + '0000000011' + '1111111111', '0001' + '0000000010' + '1111111', + '0010' + '000000010' + '11111111111', '0010' + '000000001' + '111111', + '1000' + '00000001' + '0000000111111']: + with self.subTest(stream=stream), self.assertRaises(qr.QRError): + parse(stream) + + def test_rotation(self): + for angle in [0, 90, 180, 270, 25, 110]: + with self.subTest(angle=angle): + _, img = make(LPA, box=8, border=12) + assert qr.decode(rotate(img, angle)) == LPA + + def test_mirrored(self): + _, img = make(LPA) + assert qr.decode(img[:, ::-1]) == LPA + + def test_perspective_and_noise(self): + _, img = make(LPA, box=10, border=8) + h, w = img.shape + corners = np.array([(40, 60), (w - 20, 30), (w - 60, h - 40), (30, h - 90)]) + arr = warp(img, qr._perspective(corners, np.array([(0, 0), (w, 0), (w, h), (0, h)]))).astype(float) + rng = np.random.default_rng(1) + arr = arr * 0.6 + 60 + rng.normal(0, 12, arr.shape) # low contrast + noise + # uneven lighting + arr += np.linspace(-40, 40, w)[None, :] + assert qr.decode(np.clip(arr, 0, 255).astype(np.uint8)) == LPA + + def test_no_code(self): + rng = np.random.default_rng(2) + assert qr.decode(rng.integers(0, 256, size=(240, 320), dtype=np.uint8)) is None + assert qr.decode(np.full((240, 320), 200, dtype=np.uint8)) is None + + def test_encoder_roundtrip(self): + for version in range(1, 21): + with self.subTest(version=version): + assert qr.decode_matrix(np.array(qr._Qr(version, b"hello").modules)) == "hello" From d70df6736625c88d87fe301b93359d117ea9221e Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:39:24 -0700 Subject: [PATCH 047/270] mici: process eSIM notifications after profile deletion (#38832) --- openpilot/system/ui/lib/cellular_manager.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpilot/system/ui/lib/cellular_manager.py b/openpilot/system/ui/lib/cellular_manager.py index 97f20b5bea..afbea475ca 100644 --- a/openpilot/system/ui/lib/cellular_manager.py +++ b/openpilot/system/ui/lib/cellular_manager.py @@ -145,7 +145,10 @@ class CellularManager: self._run_operation(switch, "Failed to switch eSIM profile", relist=False) def delete_profile(self, iccid: str): - self._run_operation(lambda lpa: lpa.delete_profile(iccid), "Failed to delete eSIM profile") + def delete(lpa: LPABase): + execute_and_process_notifications(lpa, lambda: lpa.delete_profile(iccid)) + + self._run_operation(delete, "Failed to delete eSIM profile") def nickname_profile(self, iccid: str, nickname: str): self._run_operation(lambda lpa: lpa.nickname_profile(iccid, nickname), "Failed to update eSIM profile nickname") From 179718dcb0c71460da7ed67bb7cd4156324ac8e1 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:16:17 -0700 Subject: [PATCH 048/270] esim: mici profile download UI (#38813) * esim: MICI profile download UI * Show temporary feedback for non-LPA QR codes * Scan eSIM QR codes every 250 ms * Shrink eSIM error text and log displayed errors * Activate downloaded eSIM through the profile tap flow * Let the eSIM connectivity error wrap naturally * Release add-profile button feedback immediately * Check internet before opening the eSIM QR scanner * Use UI network state and standard connectivity dialog for eSIM * Drop LPA prime settings change from eSIM download UI * Scope eSIM UI constants to their owning classes * Remove unused ot codespell exception * esim: simplify profile download operation and UI state * esim: share activation code validation with QR scanner * esim: match QR scanner video to cabin preview * esim: show errors in a scrollable text dialog * esim: require nonblank profile nicknames --- openpilot/common/esim/lpa.py | 2 +- .../mici/layouts/settings/network/esim_ui.py | 195 +++++++++++++++++- openpilot/selfdrive/ui/mici/widgets/dialog.py | 10 +- openpilot/system/ui/lib/cellular_manager.py | 3 + 4 files changed, 197 insertions(+), 13 deletions(-) diff --git a/openpilot/common/esim/lpa.py b/openpilot/common/esim/lpa.py index fb128c32e1..600869fcb7 100644 --- a/openpilot/common/esim/lpa.py +++ b/openpilot/common/esim/lpa.py @@ -613,7 +613,7 @@ def parse_lpa_activation_code(activation_code: str) -> tuple[str, str]: if not activation_code.startswith("LPA:"): raise ValueError("Invalid activation code format") parts = activation_code[4:].split("$") - if len(parts) != 3: + if len(parts) != 3 or not all(parts): raise ValueError("Invalid activation code format") return parts[1], parts[2] diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 0e115b232f..9af7bcde88 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -1,14 +1,27 @@ +import threading + +import numpy as np import pyray as rl from collections.abc import Callable +from openpilot.cereal import log +from openpilot.cereal.visionipc import VisionStreamType + +from openpilot.common import qrcode +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraView +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog, BigConfirmationDialog from openpilot.common.esim.base import Profile +from openpilot.common.esim.lpa import parse_lpa_activation_code from openpilot.system.ui.lib.application import DEFAULT_TEXT_COLOR, FontWeight, MousePos, TextAlignment, gui_app from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.label import gui_label -from openpilot.system.ui.widgets.scroller import NavScroller +from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label +from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller class ProfileActionButton(Widget): @@ -40,6 +53,110 @@ class ProfileActionButton(Widget): gui_label(self._rect, "Aa", 30, color=color, alignment=TextAlignment.CENTER) +class QRScannerDialog(NavWidget): + SCAN_INTERVAL_S = 0.25 + INVALID_CODE_DURATION_S = 1.0 + + def __init__(self, on_qr_detected: Callable[[str], None]): + super().__init__() + self._on_qr_detected = on_qr_detected + self._camera_view = CabinCameraView("camerad", VisionStreamType.VISION_STREAM_CABIN) + self._detected = False + self._last_scan_time = 0.0 + self._invalid_code_until = 0.0 + self._scan_thread: threading.Thread | None = None + self._scan_result: str | None = None + self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + + def show_event(self): + super().show_event() + ui_state.params.put_bool("DisableDriverCameraIR", True) + ui_state.params.put_bool("IsDriverViewEnabled", True) + + def hide_event(self): + super().hide_event() + ui_state.params.put_bool("IsDriverViewEnabled", False) + ui_state.params.put_bool("DisableDriverCameraIR", False) + + def __del__(self): + self._camera_view.close() + + def _update_state(self): + super()._update_state() + + now = rl.get_time() + if self._detected or not self._camera_view.frame or now < self._invalid_code_until: + return + + if self._scan_thread is not None: + if self._scan_thread.is_alive(): + return + self._scan_thread = None + data = self._scan_result + if data is not None: + try: + parse_lpa_activation_code(data) + except ValueError: + self._invalid_code_until = now + self.INVALID_CODE_DURATION_S + self._last_scan_time = self._invalid_code_until + else: + self._detected = True + self.dismiss(lambda: self._on_qr_detected(data)) + return + + if now - self._last_scan_time < self.SCAN_INTERVAL_S: + return + self._last_scan_time = now + + frame = self._camera_view.frame + y = np.frombuffer(frame.data, dtype=np.uint8, count=frame.height * frame.stride).reshape(frame.height, frame.stride) + gray = y[:, :frame.width].copy() # the vision buffer is recycled under the scan thread + self._scan_thread = threading.Thread(target=self._scan, args=(gray,), daemon=True) + self._scan_thread.start() + + def _scan(self, gray: np.ndarray): + self._scan_result = qrcode.decode(gray) + + def _render(self, rect): + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + self._camera_view._render(rect) + + if not self._camera_view.frame: + gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD, + alignment=TextAlignment.CENTER) + else: + label_y = rect.y + rect.height * 3 / 4 + label_rect = rl.Rectangle(rect.x, label_y + (rect.height - label_y) / 2 - 20, rect.width, 40) + text = "not an LPA code" if rl.get_time() < self._invalid_code_until else "hold QR code to camera" + gui_label(label_rect, text, font_size=32, font_weight=FontWeight.MEDIUM, + alignment=TextAlignment.CENTER, + color=rl.Color(255, 255, 255, int(255 * 0.9))) + + rl.end_scissor_mode() + + +class InstallingProfileDialog(BigDialog): + DOT_STEP = 0.6 + + def __init__(self): + super().__init__("installing profile", "please wait...") + self._show_time = 0.0 + + def show_event(self): + super().show_event() + self._nav_bar._alpha = 0.0 + self._show_time = rl.get_time() + + def _back_enabled(self) -> bool: + return False + + def _render(self, _): + t = (rl.get_time() - self._show_time) % (self.DOT_STEP * 2) + dots = "." * min(int(t / (self.DOT_STEP / 4)), 3) + self._card.set_value(f"please wait{dots}") + super()._render(_) + + class EsimProfileButton(BigButton): SUB_LABEL_DISABLED = rl.Color(255, 255, 255, int(255 * 0.585)) CHECK_ICON_COLOR = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) @@ -92,7 +209,8 @@ class EsimProfileButton(BigButton): def _on_rename(self): current = self._profile.nickname or "" - dlg = BigInputDialog("nickname", default_text=current, minimum_length=0, confirm_callback=self._on_nickname_entered) + dlg = BigInputDialog("nickname", default_text=current, confirm_callback=self._on_nickname_entered, + text_validator=lambda text: bool(text.strip())) gui_app.push_widget(dlg) def _on_delete(self): @@ -171,6 +289,25 @@ class EsimProfileButton(BigButton): self._rename_btn.set_touch_valid_callback(touch_callback) +class EsimErrorDialog(NavRawScrollPanel): + def __init__(self, error: str): + super().__init__() + self._title = UnifiedLabel("esim error", font_size=64, font_weight=FontWeight.BOLD) + self._error = UnifiedLabel(error, font_size=36, elide=False) + + def _render(self, rect: rl.Rectangle): + width = int(rect.width - 80) + title_height = self._title.get_content_height(width) + error_height = self._error.get_content_height(width) + offset = self._scroll_panel.update(rect, title_height + error_height + 100) + y = rect.y + 40 + offset + + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + self._title.render(rl.Rectangle(rect.x + 40, y, width, title_height)) + self._error.render(rl.Rectangle(rect.x + 40, y + title_height + 20, width, error_height)) + rl.end_scissor_mode() + + class EsimUI(NavScroller): def __init__(self, cellular_manager: CellularManager, profiles_enabled: Callable[[], bool]): super().__init__() @@ -178,7 +315,12 @@ class EsimUI(NavScroller): self._cellular_manager = cellular_manager self._profiles_enabled = profiles_enabled - self._cellular_manager.on_profiles_updated = self._update_buttons + self._add_profile_btn = BigButton("add profile", "scan QR code") + self._add_profile_btn.set_click_callback(self._on_add_profile) + self._scroller.add_widget(self._add_profile_btn) + self._installing_dialog: InstallingProfileDialog | None = None + + self._cellular_manager.on_profiles_updated = self._on_profiles_updated self._cellular_manager.on_operation_error = self._on_error def show_event(self): @@ -186,8 +328,18 @@ class EsimUI(NavScroller): self._update_buttons(re_sort=True) self._cellular_manager.refresh_profiles() + def _on_profiles_updated(self): + if self._installing_dialog: + existing = {btn.profile.iccid for btn in self._scroller.items if isinstance(btn, EsimProfileButton)} + added = [profile for profile in self._cellular_manager.profiles if profile.iccid not in existing] + # Start the normal tap-to-activate flow once the profile list is visible again. + self._installing_dialog.dismiss(lambda: self._on_profile_clicked(added[0]) if len(added) == 1 else None) + self._installing_dialog = None + + self._update_buttons() + def _update_buttons(self, re_sort: bool = False): - existing = {btn.profile.iccid: btn for btn in self._scroller.items} + existing = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, EsimProfileButton)} buttons = [] for profile in self._cellular_manager.profiles: btn = existing.get(profile.iccid) @@ -204,8 +356,11 @@ class EsimUI(NavScroller): else: self._scroller.items[:] = [btn for btn in self._scroller.items if btn in buttons] + self._scroller.items.append(self._add_profile_btn) + def _move_profile_to_front(self, iccid: str | None, scroll: bool = False): - front_btn_idx = next((i for i, btn in enumerate(self._scroller.items) if btn.profile.iccid == iccid), None) if iccid else None + front_btn_idx = next((i for i, btn in enumerate(self._scroller.items) + if isinstance(btn, EsimProfileButton) and btn.profile.iccid == iccid), None) if iccid else None if front_btn_idx is not None and front_btn_idx > 0: self._scroller.move_item(front_btn_idx, 0) @@ -216,13 +371,37 @@ class EsimUI(NavScroller): def _update_state(self): super()._update_state() + self._add_profile_btn.set_enabled(not self._cellular_manager.busy and self._profiles_enabled()) active = self._cellular_manager.active_profile self._move_profile_to_front(active.iccid if active else None) - def _on_error(self, error: str): - dlg = BigDialog("esim error", error) + def _on_add_profile(self): + if self._cellular_manager.busy or not self._profiles_enabled(): + return + if ui_state.sm["deviceState"].networkType == log.DeviceState.NetworkType.none: + gui_app.push_widget(BigDialog("", tr("Ensure you're connected to the internet and try again."))) + return + gui_app.push_widget(QRScannerDialog(on_qr_detected=self._on_qr_scanned)) + + def _on_qr_scanned(self, lpa_code: str): + dlg = BigInputDialog("enter a nickname...", text_validator=lambda text: bool(text.strip()), + confirm_callback=lambda nickname: self._download_profile(lpa_code, nickname)) gui_app.push_widget(dlg) + def _download_profile(self, lpa_code: str, nickname: str): + self._installing_dialog = InstallingProfileDialog() + gui_app.push_widget(self._installing_dialog) + self._cellular_manager.download_profile(lpa_code, nickname.strip()) + + def _on_error(self, error: str): + cloudlog.error("eSIM error: %s", error) + dlg = EsimErrorDialog(error) + if self._installing_dialog: + self._installing_dialog.dismiss(lambda: gui_app.push_widget(dlg)) + self._installing_dialog = None + else: + gui_app.push_widget(dlg) + def _on_profile_clicked(self, profile: Profile): if self._cellular_manager.busy or not self._profiles_enabled(): return diff --git a/openpilot/selfdrive/ui/mici/widgets/dialog.py b/openpilot/selfdrive/ui/mici/widgets/dialog.py index 77dfa3cb97..c023369933 100644 --- a/openpilot/selfdrive/ui/mici/widgets/dialog.py +++ b/openpilot/selfdrive/ui/mici/widgets/dialog.py @@ -76,14 +76,15 @@ class BigInputDialog(BigDialogBase): default_text: str = "", minimum_length: int = 1, confirm_callback: Callable[[str], None] | None = None, - auto_return_to_letters: str = ""): + auto_return_to_letters: str = "", + text_validator: Callable[[str], bool] | None = None): super().__init__() self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)), font_weight=FontWeight.MEDIUM) self._keyboard = MiciKeyboard(auto_return_to_letters=auto_return_to_letters) self._keyboard.set_text(default_text) self._keyboard.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget - self._minimum_length = minimum_length + self._text_valid = lambda text: len(text) >= minimum_length and (text_validator is None or text_validator(text)) self._backspace_held_time: float | None = None @@ -100,7 +101,8 @@ class BigInputDialog(BigDialogBase): def confirm_callback_wrapper(): text = self._keyboard.text() - self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None) + if self._text_valid(text): + self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None) self._confirm_callback = confirm_callback_wrapper def _update_state(self): @@ -185,7 +187,7 @@ class BigInputDialog(BigDialogBase): self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height) # draw enter button - self._enter_img_alpha.update(255 if len(text) >= self._minimum_length else 0) + self._enter_img_alpha.update(255 if self._text_valid(text) else 0) color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x)) rl.draw_texture_ex(self._enter_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y), 0.0, 1.0, color) color = rl.Color(255, 255, 255, 255 - int(self._enter_img_alpha.x)) diff --git a/openpilot/system/ui/lib/cellular_manager.py b/openpilot/system/ui/lib/cellular_manager.py index afbea475ca..7cd19c4cb3 100644 --- a/openpilot/system/ui/lib/cellular_manager.py +++ b/openpilot/system/ui/lib/cellular_manager.py @@ -150,5 +150,8 @@ class CellularManager: self._run_operation(delete, "Failed to delete eSIM profile") + def download_profile(self, qr: str, nickname: str | None = None): + self._run_operation(lambda lpa: lpa.download_profile(qr, nickname), "Failed to download eSIM profile") + def nickname_profile(self, iccid: str, nickname: str): self._run_operation(lambda lpa: lpa.nickname_profile(iccid, nickname), "Failed to update eSIM profile nickname") From 802a231bd20c6a0b0b88ebbb891970aee40cf1b5 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:34:37 -0700 Subject: [PATCH 049/270] mici: simplify eSIM connecting status (#38833) --- .../selfdrive/ui/mici/layouts/settings/network/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py index 4719feecb9..89c106e236 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -35,7 +35,7 @@ class EsimNetworkButton(BigButton): def _compute_state(self): cm = self._cellular_manager none_icon = self._cell_icons[NetworkStrength.unknown] - ip = cm.modem_state.get("ip_address") or "obtaining IP..." + ip = cm.modem_state.get("ip_address") or "connecting..." if cm.is_euicc is False: iccid = cm.modem_state.get("iccid") or "" if not iccid: From 14617070ab79a133dd22f1a00009f24b3ab2affc Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Wed, 9 Sep 2026 09:32:32 -0700 Subject: [PATCH 050/270] lil more comment --- tools/setup_dependencies.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/setup_dependencies.sh b/tools/setup_dependencies.sh index 5ad833a5ca..7af2180686 100755 --- a/tools/setup_dependencies.sh +++ b/tools/setup_dependencies.sh @@ -42,6 +42,10 @@ function install_linux_deps() { # dependencies should never be added to this list. # these are only for inflating bare docker images # to their desktop equivalents. + # + # from a real desktop OS image, we install uv + # then use that to install all the dependencies + # specified in pyproject.toml. # ------------------------------------------------ if [[ "$missing_linux_deps" -eq 0 ]]; then # the native package managers are slow, so skip if we can From 20bddff85706ef9a5ba778333a6327a8cdc759eb Mon Sep 17 00:00:00 2001 From: Amy Jeanes Date: Mon, 7 Sep 2026 23:56:47 +0000 Subject: [PATCH 051/270] Use LF line endings Split from https://github.com/commaai/openpilot/pull/38810. --- .gitattributes | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 5cb404146d..2f8f3ece32 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,4 @@ -* text=auto +* text=auto eol=lf # to move existing files into LFS: # git add --renormalize . From 1e0914051fd22d963309b77996181d8b9a97e267 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Wed, 9 Sep 2026 09:57:22 -0700 Subject: [PATCH 052/270] bump msgq (#38836) --- msgq_repo | 2 +- pyproject.toml | 4 ++-- uv.lock | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/msgq_repo b/msgq_repo index 0e266c1dbc..326a9f5aa6 160000 --- a/msgq_repo +++ b/msgq_repo @@ -1 +1 @@ -Subproject commit 0e266c1dbcf7328beee3e57b4a8688555387c877 +Subproject commit 326a9f5aa6cf630f647fd6996106aa267dd01c2a diff --git a/pyproject.toml b/pyproject.toml index 7be8f97c0f..2c97e6444e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ tools = [ ] submodules = [ - "msgq", + "msgq-ipc", "opendbc", "pandacan", "rednose", @@ -155,7 +155,7 @@ override-dependencies = [ ] [tool.uv.sources] -msgq = { path = "msgq_repo", editable = true } +msgq-ipc = { path = "msgq_repo", editable = true } opendbc = { path = "opendbc_repo", editable = true } pandacan = { path = "panda", editable = true } rednose = { path = "rednose_repo", editable = true } diff --git a/uv.lock b/uv.lock index c27429f5f2..fe85a470bc 100644 --- a/uv.lock +++ b/uv.lock @@ -512,8 +512,8 @@ wheels = [ ] [[package]] -name = "msgq" -version = "0.0.1" +name = "msgq-ipc" +version = "1.1" source = { editable = "msgq_repo" } [package.metadata] @@ -618,7 +618,7 @@ dependencies = [ [package.optional-dependencies] submodules = [ - { name = "msgq" }, + { name = "msgq-ipc" }, { name = "opendbc" }, { name = "pandacan" }, { name = "rednose" }, @@ -664,7 +664,7 @@ requires-dist = [ { name = "inputs" }, { name = "jeepney" }, { name = "matplotlib", marker = "extra == 'tools'" }, - { name = "msgq", marker = "extra == 'submodules'", editable = "msgq_repo" }, + { name = "msgq-ipc", marker = "extra == 'submodules'", editable = "msgq_repo" }, { name = "numpy", specifier = ">=2.0" }, { name = "opendbc", marker = "extra == 'submodules'", editable = "opendbc_repo" }, { name = "pandacan", marker = "extra == 'submodules'", editable = "panda" }, From ff2f78c3e83c2104edca826a9af78445e47afee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Wed, 9 Sep 2026 14:31:06 -0700 Subject: [PATCH 053/270] Move Git LFS hosting to Hugging Face (#38824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move LFS asset transfers to xet-core * Limit migration to Git LFS hosting on Hugging Face --------- Co-authored-by: Harald Schäfer <6804392+haraschax@users.noreply.github.com> --- .lfsconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.lfsconfig b/.lfsconfig index 42dfa2d944..784a2d8705 100644 --- a/.lfsconfig +++ b/.lfsconfig @@ -1,4 +1,4 @@ [lfs] - url = https://gitlab.com/commaai/openpilot-lfs.git/info/lfs - pushurl = ssh://git@gitlab.com/commaai/openpilot-lfs.git + url = https://huggingface.co/commaai/openpilot-lfs.git/info/lfs + pushurl = https://huggingface.co/commaai/openpilot-lfs.git/info/lfs locksverify = false From d9ecb0b678e95d88dfbb7a1c4fe7b07a0247b09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Wed, 9 Sep 2026 18:14:47 -0700 Subject: [PATCH 054/270] Revert "Move Git LFS hosting to Hugging Face" (#38837) --- .lfsconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.lfsconfig b/.lfsconfig index 784a2d8705..42dfa2d944 100644 --- a/.lfsconfig +++ b/.lfsconfig @@ -1,4 +1,4 @@ [lfs] - url = https://huggingface.co/commaai/openpilot-lfs.git/info/lfs - pushurl = https://huggingface.co/commaai/openpilot-lfs.git/info/lfs + url = https://gitlab.com/commaai/openpilot-lfs.git/info/lfs + pushurl = ssh://git@gitlab.com/commaai/openpilot-lfs.git locksverify = false From b97a2d82018efe4a65cc961ea962c30df88d47b0 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:21:52 -0700 Subject: [PATCH 055/270] eSIM: require internet to delete a profile (#38838) * Guard eSIM profile deletion with internet connectivity check * Simplify eSIM delete connectivity guard --- .../selfdrive/ui/mici/layouts/settings/network/esim_ui.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py index 9af7bcde88..b54edec427 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -219,6 +219,9 @@ class EsimProfileButton(BigButton): def _delete_profile(self): if not self._locked and not self._cellular_manager.busy and self._show_delete_btn: + if ui_state.sm["deviceState"].networkType == log.DeviceState.NetworkType.none: + gui_app.push_widget(BigDialog("", tr("Ensure you're connected to the internet and try again."))) + return self._cellular_manager.delete_profile(self._profile.iccid) def _on_nickname_entered(self, nickname: str): From 0cd3f0f20bcf837d9fdcff3266730ee625f54784 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 11:18:54 -0700 Subject: [PATCH 056/270] remove old manifest before rebuild (#38842) invalidate manifest before rebuilt --- openpilot/common/file_chunker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpilot/common/file_chunker.py b/openpilot/common/file_chunker.py index 2d080c3fff..5bf30de9ab 100755 --- a/openpilot/common/file_chunker.py +++ b/openpilot/common/file_chunker.py @@ -24,6 +24,7 @@ def chunk_file(path, targets): manifest_path, *chunk_paths = targets actual_num_chunks = max(1, math.ceil(os.path.getsize(path) / CHUNK_SIZE)) assert len(chunk_paths) >= actual_num_chunks, f"Allowed {len(chunk_paths)} chunks but needs at least {actual_num_chunks}, for path {path}" + Path(manifest_path).unlink(missing_ok=True) with open(path, 'rb') as f: for chunk_path in chunk_paths: with open(chunk_path, 'wb') as out: From 68d829c7c9e7485f03d64f1f201f8cda137ee9d0 Mon Sep 17 00:00:00 2001 From: Zeph Date: Thu, 10 Sep 2026 14:14:54 -0500 Subject: [PATCH 057/270] selfdrived: no localizer alerts from capnp defaults (#38840) selfdrived: don't alert on a message that was never received posenetInvalid, locationdTemporaryError and paramsdTemporaryError are raised off the capnp defaults of deviceMotion and vehicleParameters before either message has ever arrived. When modeld is down, locationd and paramsd never publish, the defaults read as a nan posenet speed, and that NoEntryAlert can hide the process not running alert that names modeld. Same guard as the big model alert in #38500. --- openpilot/selfdrive/selfdrived/selfdrived.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 638448d897..f7636ce427 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -398,11 +398,12 @@ class SelfdriveD: self.logged_comm_issue = None if not self.CP.notCar and not big_model_settling: # localization has nothing to work with during the load - if not self.sm['deviceMotion'].posenetOK: + # the defaults of a message that was never received are not a localizer failure + if self.sm.seen['deviceMotion'] and not self.sm['deviceMotion'].posenetOK: self.events.add(EventName.posenetInvalid) - if not self.sm['deviceMotion'].inputsOK: + if self.sm.seen['deviceMotion'] and not self.sm['deviceMotion'].inputsOK: self.events.add(EventName.locationdTemporaryError) - if (not self.sm['vehicleParameters'].valid and cal_status == log.ExtrinsicsCalibration.Status.calibrated and + if (self.sm.seen['vehicleParameters'] and not self.sm['vehicleParameters'].valid and cal_status == log.ExtrinsicsCalibration.Status.calibrated and not TESTING_CLOSET and (not SIMULATION or REPLAY)): self.events.add(EventName.paramsdTemporaryError) From f174e39d7a799d2523da0650e043973a3389451e Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 13:19:44 -0700 Subject: [PATCH 058/270] add powertest for mici (#38843) --- Jenkinsfile | 1 + .../common/hardware/comma/power_monitor.py | 13 +++++++----- openpilot/selfdrive/test/test_power_draw.py | 21 +++++++++++++++---- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index b6b3eec5e3..2aa522e9d3 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -256,6 +256,7 @@ node { deviceStage("chestnut", "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [ step("build", "./openpilot/selfdrive/test/chestnut.sh"), step("model replay", "openpilot/selfdrive/test/process_replay/model_replay.py --chestnut"), + step("test power draw", "./openpilot/selfdrive/test/test_power_draw.py"), ]) }, diff --git a/openpilot/common/hardware/comma/power_monitor.py b/openpilot/common/hardware/comma/power_monitor.py index 296290dae8..9ae2c9e138 100755 --- a/openpilot/common/hardware/comma/power_monitor.py +++ b/openpilot/common/hardware/comma/power_monitor.py @@ -9,22 +9,25 @@ from openpilot.common.realtime import Ratekeeper from openpilot.common.filter_simple import FirstOrderFilter -def read_power(): +def read_power(panda=None): + if panda is not None and panda.get_type() == panda.HW_TYPE_CUATRO: + health = panda.health() + return health['voltage'] * health['current'] / 1e6 with open("/sys/bus/i2c/devices/0-0040/hwmon/hwmon1/power1_input") as f: return int(f.read()) / 1e6 -def sample_power(seconds=5) -> list[float]: +def sample_power(seconds=5, panda=None) -> list[float]: rate = 123 rk = Ratekeeper(rate, print_delay_threshold=None) pwrs = [] for _ in range(rate*seconds): - pwrs.append(read_power()) + pwrs.append(read_power(panda)) rk.keep_time() return pwrs -def get_power(seconds=5): - pwrs = sample_power(seconds) +def get_power(seconds=5, panda=None): + pwrs = sample_power(seconds, panda) return np.mean(pwrs) def wait_for_power(min_pwr, max_pwr, min_secs_in_range, timeout): diff --git a/openpilot/selfdrive/test/test_power_draw.py b/openpilot/selfdrive/test/test_power_draw.py index 1d0be24ef0..d8b234fe60 100755 --- a/openpilot/selfdrive/test/test_power_draw.py +++ b/openpilot/selfdrive/test/test_power_draw.py @@ -5,6 +5,8 @@ import time import unittest import numpy as np from dataclasses import dataclass +from panda import Panda +from openpilot.common.hardware import HARDWARE from openpilot.common.test import OpenpilotTestCase from openpilot.common.utils import tabulate @@ -14,11 +16,14 @@ from opendbc.car.car_helpers import get_demo_car_params from openpilot.common.mock import mock_messages from openpilot.common.params import Params from openpilot.common.hardware.comma.power_monitor import get_power +from openpilot.selfdrive.modeld.helpers import chestnut_present from openpilot.system.manager.process_config import managed_processes from openpilot.system.manager.manager import manager_cleanup SAMPLE_TIME = 2 # seconds to sample power MAX_WARMUP_TIME = 30 # seconds to wait for SAMPLE_TIME consecutive valid samples +MICI = HARDWARE.get_device_type() == "mici" +CHESTNUT = chestnut_present() @dataclass class Proc: @@ -33,9 +38,10 @@ class Proc: return '+'.join(self.procs) +# MICI readings exclude the separately powered Chestnut GPU. PROCS = [ - Proc(['camerad'], 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']), - Proc(['modeld'], 1.5, atol=0.2, msgs=['modelV2']), + Proc(['camerad'], 0.85 if MICI else 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']), + Proc(['modeld'], 0.45 if MICI and CHESTNUT else 1.5, atol=0.2, msgs=['modelV2']), Proc(['dmonitoringmodeld'], 0.65, atol=0.35, msgs=['driverStateV2']), Proc(['encoderd'], 0.23, msgs=[]), ] @@ -46,6 +52,13 @@ class TestPowerDraw(OpenpilotTestCase): def setup_method(self): Params().put("CarParams", get_demo_car_params().to_bytes(), block=True) + self.panda = None + if MICI: + HARDWARE.reset_internal_panda() + self.addCleanup(HARDWARE.reset_internal_panda) + Panda.wait_for_panda(None, 30) + self.panda = Panda(cli=False) + self.addCleanup(self.panda.close) def teardown_method(self): manager_cleanup() @@ -78,7 +91,7 @@ class TestPowerDraw(OpenpilotTestCase): start_time = time.monotonic() while (time.monotonic() - start_time) < MAX_WARMUP_TIME: - power = get_power(1) + power = get_power(1, self.panda) iteration_msg_counts = {} for msg,sock in socks.items(): iteration_msg_counts[msg] = len(messaging.drain_sock_raw(sock)) @@ -97,7 +110,7 @@ class TestPowerDraw(OpenpilotTestCase): @mock_messages(['deviceMotion']) def test_camera_procs(self, subtests): - baseline = get_power() + baseline = get_power(panda=self.panda) prev = baseline used = {} From 633117b2a9c43aa3fb36d7a4eb58bf0c98b2688d Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 14:02:18 -0700 Subject: [PATCH 059/270] test_onroad in chestnut CI (#38844) test chestnut with live camera frames --- Jenkinsfile | 1 + openpilot/selfdrive/test/test_onroad.py | 44 ++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index 2aa522e9d3..d94e3aa2be 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -256,6 +256,7 @@ node { deviceStage("chestnut", "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [ step("build", "./openpilot/selfdrive/test/chestnut.sh"), step("model replay", "openpilot/selfdrive/test/process_replay/model_replay.py --chestnut"), + step("onroad tests", "./openpilot/selfdrive/test/test_onroad.py TestChestnutOnroad", [timeout: 120]), step("test power draw", "./openpilot/selfdrive/test/test_power_draw.py"), ]) }, diff --git a/openpilot/selfdrive/test/test_onroad.py b/openpilot/selfdrive/test/test_onroad.py index f524046abe..0b64b3fb39 100755 --- a/openpilot/selfdrive/test/test_onroad.py +++ b/openpilot/selfdrive/test/test_onroad.py @@ -20,8 +20,12 @@ from openpilot.common.basedir import BASEDIR from openpilot.common.timeout import Timeout from openpilot.common.params import Params from openpilot.selfdrive.selfdrived.events import EVENTS, ET -from openpilot.selfdrive.test.helpers import set_params_enabled, release_only +from openpilot.selfdrive.test.helpers import set_params_enabled, release_only, processes_context, log_collector +from openpilot.common.hardware import HARDWARE from openpilot.common.hardware.hw import Paths +from openpilot.common.mock import mock_messages +from opendbc.car.car_helpers import get_demo_car_params +from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled from openpilot.tools.lib.logreader import LogReader from openpilot.tools.lib.log_time_series import msgs_to_time_series @@ -457,5 +461,43 @@ class TestOnroad(OpenpilotTestCase): f"Not engageable for whole segment:\n- selfdriveState.engageable: {Counter(eng)}\n- No entry events: {no_entries}" +@unittest.skipUnless(HARDWARE.get_device_type() == "mici", "requires MICI") +class TestChestnutOnroad(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True + + @mock_messages(['deviceMotion']) + def test_camera_models(self, subtests): + assert chestnut_present() and chestnut_compiled() + Params().put("CarParams", get_demo_car_params().to_bytes(), block=True) + services = ['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState', 'modelV2', 'driverStateV2'] + sm = messaging.SubMaster(services) + pm = messaging.PubMaster(['deviceState']) + device_state = messaging.new_message('deviceState') + device_state.deviceState.deviceType = HARDWARE.get_device_type() + device_state_bytes = device_state.to_bytes() + with processes_context(['camerad', 'calibrationd', 'modeld', 'dmonitoringmodeld']): + with Timeout(60, "camera models didn't start"): + while not all(sm.seen.values()) or not sm.valid['modelV2']: + pm.send('deviceState', device_state_bytes) + sm.update(1000) + with log_collector(services) as (logs, _): + time.sleep(TEST_DURATION) + + msgs = {s: [m for m in logs if m.which() == s] for s in services} + for service, messages in msgs.items(): + with subtests.test(service=service): + expected = TEST_DURATION * SERVICE_LIST[service].frequency + assert np.isclose(len(messages), expected, rtol=0.05, atol=2), f"{service}: expected {expected}, got {len(messages)}" + assert all(m.valid for m in messages) + frame_ids = [getattr(m, service).frameId for m in messages] + assert np.all(np.diff(frame_ids) > 0), f"{service}: repeated or reordered frames" + + camera_frames = {m.narrowRoadCameraState.frameId for m in msgs['narrowRoadCameraState']} + model_frames = {m.modelV2.frameId for m in msgs['modelV2']} + assert len(camera_frames & model_frames) >= TEST_DURATION * SERVICE_LIST['modelV2'].frequency * 0.9 + assert all(m.modelV2.big for m in msgs['modelV2']), "Chestnut fell back to the small model" + assert all(np.isfinite(m.modelV2.position.x).all() for m in msgs['modelV2']) + + if __name__ == "__main__": unittest.main() From 443967ddadc6ca5c27d416dea48f114102c22428 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 14:17:21 -0700 Subject: [PATCH 060/270] enable caching in chestnut CI (#38845) enable caching --- openpilot/selfdrive/test/chestnut.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/test/chestnut.sh b/openpilot/selfdrive/test/chestnut.sh index 1ffd12c9e4..8ccc6722de 100755 --- a/openpilot/selfdrive/test/chestnut.sh +++ b/openpilot/selfdrive/test/chestnut.sh @@ -3,5 +3,5 @@ set -e TARGET=openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest rm -f "$TARGET" -SCONSFLAGS="-j2 --cache-disable" ./openpilot/system/manager/build.py +SCONSFLAGS="-j4" ./openpilot/system/manager/build.py test -s "$TARGET" From 511c1f1e4e0ddd7a3006431799b81979ff65bf41 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 14:28:12 -0700 Subject: [PATCH 061/270] chestnut updater: close files explicitly (#38846) chestnut: close USB sysfs files after reading --- openpilot/system/hardware/chestnut/flash.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpilot/system/hardware/chestnut/flash.py b/openpilot/system/hardware/chestnut/flash.py index f0d828031d..d2c8e1dfa3 100755 --- a/openpilot/system/hardware/chestnut/flash.py +++ b/openpilot/system/hardware/chestnut/flash.py @@ -63,9 +63,9 @@ def find_chestnut(): found = [] for d in glob.glob("/sys/bus/usb/devices/*"): try: - vid_pid = (open(d + "/idVendor").read().strip(), open(d + "/idProduct").read().strip()) + vid_pid = (Path(d, "idVendor").read_text().strip(), Path(d, "idProduct").read_text().strip()) if vid_pid in VID_PIDS + ROM_VID_PIDS: - found.append((d, vid_pid, open(d + "/product").read().strip())) + found.append((d, vid_pid, Path(d, "product").read_text().strip())) except OSError: pass if len(found) > 1: @@ -101,7 +101,7 @@ def unbind_drivers(path): def open_device(path): - bus, dev = int(open(path + "/busnum").read()), int(open(path + "/devnum").read()) + bus, dev = int(Path(path, "busnum").read_text()), int(Path(path, "devnum").read_text()) return os.open(f"/dev/bus/usb/{bus:03d}/{dev:03d}", os.O_RDWR) From 9b899fdaa217533042affa43b566758b42bedac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Thu, 10 Sep 2026 16:47:40 -0700 Subject: [PATCH 062/270] Reapply Git LFS hosting on Hugging Face (#38841) --- .lfsconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.lfsconfig b/.lfsconfig index 42dfa2d944..784a2d8705 100644 --- a/.lfsconfig +++ b/.lfsconfig @@ -1,4 +1,4 @@ [lfs] - url = https://gitlab.com/commaai/openpilot-lfs.git/info/lfs - pushurl = ssh://git@gitlab.com/commaai/openpilot-lfs.git + url = https://huggingface.co/commaai/openpilot-lfs.git/info/lfs + pushurl = https://huggingface.co/commaai/openpilot-lfs.git/info/lfs locksverify = false From e9711bc51d10fa16b59a30475830464d24bb0c41 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 17:26:33 -0700 Subject: [PATCH 063/270] longer SSH keepalive for chestnut model load (#38851) ci: allow longer SSH keepalive gaps --- Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index d94e3aa2be..0f4945dab6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -12,7 +12,7 @@ def retryWithDelay(int maxRetries, int delay, Closure body) { def device(String ip, String step_label, String cmd) { withCredentials([file(credentialsId: 'id_rsa', variable: 'key_file')]) { def ssh_cmd = """ -ssh -o ControlMaster=auto -o ControlPath=/tmp/ssh_control_%C -o ControlPersist=yes -o ConnectTimeout=5 -o ServerAliveInterval=5 -o ServerAliveCountMax=2 -o BatchMode=yes -o StrictHostKeyChecking=no -i ${key_file} 'comma@${ip}' exec /usr/bin/bash <<'END' +ssh -o ControlMaster=auto -o ControlPath=/tmp/ssh_control_%C -o ControlPersist=yes -o ConnectTimeout=5 -o ServerAliveInterval=5 -o ServerAliveCountMax=12 -o BatchMode=yes -o StrictHostKeyChecking=no -i ${key_file} 'comma@${ip}' exec /usr/bin/bash <<'END' set -e From e6f401b5cfb16e6d13990fb71b00cbc573634642 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 17:38:04 -0700 Subject: [PATCH 064/270] build nightly-chestnut (#38849) build nightly-chestnut --- .github/workflows/release.yaml | 4 ++++ Jenkinsfile | 8 +++++++- openpilot/system/hardware/chestnut/status.py | 2 +- tools/release/build_release.sh | 2 +- tools/release/build_stripped.sh | 3 +++ 5 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 26b28ab330..a5a91a482b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -34,3 +34,7 @@ jobs: - run: ./tools/op.sh setup - name: Push master-ci run: BRANCH=__nightly tools/release/build_stripped.sh + - name: Push chestnut nightly + run: | + git lfs pull --exclude='' + INCLUDE_BIG_MODEL=1 BRANCH=__nightly-chestnut tools/release/build_stripped.sh diff --git a/Jenkinsfile b/Jenkinsfile index 0f4945dab6..d46d599457 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -169,7 +169,7 @@ node { env.GIT_BRANCH = checkout(scm).GIT_BRANCH env.GIT_COMMIT = checkout(scm).GIT_COMMIT - def excludeBranches = ['__nightly', 'devel', 'devel-staging', + def excludeBranches = ['__nightly', '__nightly-chestnut', 'devel', 'devel-staging', 'release-tizi', 'release-tizi-staging', 'release-mici', 'release-mici-staging', 'testing-closet*', 'hotfix-*'] def excludeRegex = excludeBranches.join('|').replaceAll('\\*', '.*') @@ -201,6 +201,12 @@ node { ) } + if (env.BRANCH_NAME == '__nightly-chestnut') { + deviceStage("build nightly-chestnut", "mici-chestnut-ci", [], [ + step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), + ]) + } + if (!env.BRANCH_NAME.matches(excludeRegex)) { parallel ( 'onroad tests': { diff --git a/openpilot/system/hardware/chestnut/status.py b/openpilot/system/hardware/chestnut/status.py index c3321a3971..8c38987dff 100644 --- a/openpilot/system/hardware/chestnut/status.py +++ b/openpilot/system/hardware/chestnut/status.py @@ -4,7 +4,7 @@ from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, is_chestnut_usb_ from openpilot.selfdrive.modeld.helpers import chestnut_compiled -CHESTNUT_RELEASE_BRANCHES = ("release-chestnut", "release-chestnut-staging") +CHESTNUT_RELEASE_BRANCHES = ("release-chestnut", "release-chestnut-staging", "nightly-chestnut") CHESTNUT_POWERED_VOLTAGE = 5000 GPU_TEMP_LIMIT = 100. MEMORY_TEMP_LIMIT = 95. diff --git a/tools/release/build_release.sh b/tools/release/build_release.sh index 4bc5dd2e68..cced9f7a54 100755 --- a/tools/release/build_release.sh +++ b/tools/release/build_release.sh @@ -89,7 +89,7 @@ git -c core.compression=0 -c gc.auto=0 commit -m "openpilot v$VERSION" # Run tests cd $BUILD_DIR -RELEASE=1 ./openpilot/selfdrive/test/test_onroad.py +RELEASE=1 ./openpilot/selfdrive/test/test_onroad.py "$@" #tools/test_runner.py openpilot/selfdrive/car/tests/test_car_interfaces.py echo "[-] pushing release T=$SECONDS" diff --git a/tools/release/build_stripped.sh b/tools/release/build_stripped.sh index ba4c847375..a216dfe11c 100755 --- a/tools/release/build_stripped.sh +++ b/tools/release/build_stripped.sh @@ -78,6 +78,9 @@ fi if [ ! -z "$BRANCH" ]; then echo "[-] Pushing to $BRANCH T=$SECONDS" + # Reset hooks since releases exclude .venv + git config --local core.hooksPath .git/hooks + git lfs update --force # uploading the larger pack is faster than spending CPU to optimize it git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin tmp:$BRANCH fi From 2a70b40cdc315ba47ee8e2dc7c87d10269cb2478 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 18:14:23 -0700 Subject: [PATCH 065/270] rebuild models when helpers change (#38853) rebuild models when serialization helpers change --- openpilot/selfdrive/modeld/SConscript | 1 + 1 file changed, 1 insertion(+) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 9a10fed585..d2dacc8245 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -62,6 +62,7 @@ modeld_dir = Dir("#openpilot/selfdrive/modeld").abspath compile_modeld_script = [ File(f"{modeld_dir}/compile_modeld.py"), File(f"{modeld_dir}/get_model_metadata.py"), + File(f"{modeld_dir}/helpers.py"), File("#openpilot/system/camerad/cameras/nv12_info.py"), File("#openpilot/common/hardware/hw.py"), ] From f87e170bee9d90676fbe1eae7deff45ab7f6d610 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 18:17:40 -0700 Subject: [PATCH 066/270] reject incomplete model buffers (#38852) --- openpilot/selfdrive/modeld/helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index d081050055..0fdd1629ba 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -41,7 +41,8 @@ def load_oob(f): def buffers(): while (h := f.read(8)): pb = pickle.PickleBuffer(bytearray(struct.unpack(' Date: Thu, 10 Sep 2026 20:57:57 -0700 Subject: [PATCH 067/270] enable alpha long on nightly-chestnut (#38854) panda debug build --- Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index d46d599457..d2eeaa1600 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -203,7 +203,7 @@ node { if (env.BRANCH_NAME == '__nightly-chestnut') { deviceStage("build nightly-chestnut", "mici-chestnut-ci", [], [ - step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), + step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 PANDA_DEBUG_BUILD=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), ]) } From 6c410eee37fed231a2d61c0b8d24edb3c90cb5ec Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 10 Sep 2026 21:06:22 -0700 Subject: [PATCH 068/270] validate compiled model (#38855) * validate serialized models after writing --- openpilot/selfdrive/modeld/compile_modeld.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py index d851c3cc86..52be6897c0 100755 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ b/openpilot/selfdrive/modeld/compile_modeld.py @@ -329,4 +329,7 @@ if __name__ == "__main__": with open(args.output, "wb") as f: dump_oob(out, f) + with open(args.output, "rb") as f: + load_oob(f) + assert not f.read(1), "unexpected model buffer data" print(f"Saved JITs to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB)") From 6107da1cd655e3b2ac44c0b28728ab0619e42eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Thu, 10 Sep 2026 21:24:09 -0700 Subject: [PATCH 069/270] Cinque v2 (#38850) 1a421175-db71-4e3d-9d62-e2166421b02b/12864 --- openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx index af98beabcb..13e3d32f47 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx +++ b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e8d821733be15ebe9e27498bc27ad8bbbd741980ece37d77f377294010b8ff28 -size 765950064 +oid sha256:09d080f36965bb2a0790500452bd328aa03c484d0222aa79d1ad9f021a522aec +size 766040736 From 73720bfd08f7e74d614e3947029f82840201ba23 Mon Sep 17 00:00:00 2001 From: Bhavya Gada Date: Fri, 11 Sep 2026 15:29:13 -0700 Subject: [PATCH 070/270] Get MetaDrive simulator working on macOS (#38830) --- openpilot/selfdrive/modeld/SConscript | 5 +++-- openpilot/system/hardware/hardwared.py | 9 +++++--- openpilot/system/ui/lib/wifi_manager.py | 3 ++- openpilot/tools/sim/lib/camerad.py | 28 +++++++++++++++---------- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index d2dacc8245..2f3e19a315 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -29,8 +29,9 @@ if arch == 'comma_arm64': tg_backend = 'QCOM' tg_flags = f'DEV={tg_backend} IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' else: - tg_backend = 'CPU' - tg_flags = f'DEV=CPU' if arch == 'Darwin' else 'DEV=CPU:LLVM' + tg_backend = 'METAL' if arch == 'Darwin' else 'CPU' + # JIT=2 disables graph batching, which produces incorrect outputs after buffers change. + tg_flags = 'DEV=METAL JIT=2' if arch == 'Darwin' else 'DEV=CPU:LLVM' tg_devices = { # which device to put jit inputs to at runtime 'openpilot.selfdrive.modeld.dmonitoringmodeld': { diff --git a/openpilot/system/hardware/hardwared.py b/openpilot/system/hardware/hardwared.py index 11fe41400d..8a5436b66f 100755 --- a/openpilot/system/hardware/hardwared.py +++ b/openpilot/system/hardware/hardwared.py @@ -194,7 +194,7 @@ def hw_state_thread(end_event, hw_queue): def hardware_thread(end_event, hw_queue) -> None: - system_stats = LinuxSystemStats() + system_stats = LinuxSystemStats() if sys.platform == "linux" else None pm = messaging.PubMaster(['deviceState']) sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates", "chestnutState"], poll="pandaStates") @@ -287,10 +287,13 @@ def hardware_thread(end_event, hw_queue) -> None: except queue.Empty: pass + memory_usage = system_stats.memory_usage_percent() if system_stats is not None else 0. + cpu_usage = system_stats.cpu_usage_percent() if system_stats is not None else [] + msg.deviceState.freeSpacePercent = get_available_percent(default=100.0) - msg.deviceState.memoryUsagePercent = int(round(system_stats.memory_usage_percent())) + msg.deviceState.memoryUsagePercent = int(round(memory_usage)) msg.deviceState.gpuUsagePercent = int(round(HARDWARE.get_gpu_usage_percent())) - online_cpu_usage = [int(round(n)) for n in system_stats.cpu_usage_percent()] + online_cpu_usage = [int(round(n)) for n in cpu_usage] offline_cpu_usage = [0., ] * (len(msg.deviceState.cpuTempC) - len(online_cpu_usage)) msg.deviceState.cpuUsagePercent = online_cpu_usage + offline_cpu_usage diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 26474be942..cd487f5636 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -203,7 +203,8 @@ class WifiManager: self._scan_lock = threading.Lock() self._scan_thread = threading.Thread(target=self._network_scanner, daemon=True) self._state_thread = threading.Thread(target=self._monitor_state, daemon=True) - self._initialize() + if not self._exit: + self._initialize() atexit.register(self.stop) def _initialize(self): diff --git a/openpilot/tools/sim/lib/camerad.py b/openpilot/tools/sim/lib/camerad.py index 206e4fdf90..7d8782d9a8 100644 --- a/openpilot/tools/sim/lib/camerad.py +++ b/openpilot/tools/sim/lib/camerad.py @@ -4,6 +4,7 @@ from openpilot.cereal.visionipc import VisionStreamType from msgq.visionipc import VisionIpcServer from openpilot.cereal import messaging +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info from openpilot.tools.sim.lib.common import W, H @@ -23,17 +24,20 @@ def rgb_to_nv12(rgb): g_sub = (g[0::2, 0::2] + g[0::2, 1::2] + g[1::2, 0::2] + g[1::2, 1::2] + 2) >> 2 b_sub = (b[0::2, 0::2] + b[0::2, 1::2] + b[1::2, 0::2] + b[1::2, 1::2] + 2) >> 2 - # U and V planes + # Interleave U and V planes for NV12 format u = np.clip((b_sub * 56 - g_sub * 37 - r_sub * 19 + 0x8080) >> 8, 0, 255).astype(np.uint8) v = np.clip((r_sub * 56 - g_sub * 47 - b_sub * 9 + 0x8080) >> 8, 0, 255).astype(np.uint8) + uv = np.stack((u, v), axis=-1).reshape(h // 2, w) - # Interleave UV for NV12 format - uv = np.empty((h // 2, w), dtype=np.uint8) - uv[:, 0::2] = u - uv[:, 1::2] = v + # Copy the visible image into the aligned NV12 buffer + stride, y_height, uv_height, size = get_nv12_info(w, h) + nv12 = np.zeros(size, dtype=np.uint8) + planes = nv12[:stride * (y_height + uv_height)].reshape(-1, stride) - return np.concatenate([y.ravel(), uv.ravel()]).tobytes() + planes[:h, :w] = y + planes[y_height:y_height + h // 2, :w] = uv + return nv12.tobytes() class Camerad: """Simulates the camerad daemon""" @@ -44,9 +48,12 @@ class Camerad: self.frame_wide_id = 0 self.vipc_server = VisionIpcServer("camerad") - self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_NARROW_ROAD, 5, W, H) + stride, y_height, _, size = get_nv12_info(W, H) + buffer_args = (5, W, H, size, stride, stride * y_height) + + self.vipc_server.create_buffers_with_sizes(VisionStreamType.VISION_STREAM_NARROW_ROAD, *buffer_args) if dual_camera: - self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_WIDE_ROAD, 5, W, H) + self.vipc_server.create_buffers_with_sizes(VisionStreamType.VISION_STREAM_WIDE_ROAD, *buffer_args) self.vipc_server.start_listener() @@ -65,10 +72,9 @@ class Camerad: return rgb_to_nv12(rgb) def _send_yuv(self, yuv, frame_id, pub_type, yuv_type): - eof = int(frame_id * 0.05 * 1e9) - self.vipc_server.send(yuv_type, yuv, frame_id, eof, eof) - dat = messaging.new_message(pub_type, valid=True) + self.vipc_server.send(yuv_type, yuv, frame_id, dat.logMonoTime, dat.logMonoTime) + msg = { "frameId": frame_id, "transform": [1.0, 0.0, 0.0, From 6965492e66c924b66e5796c5916cbf868e1f1775 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Fri, 11 Sep 2026 17:25:33 -0700 Subject: [PATCH 071/270] clean up after jenkins disconnect (#38865) * fix SSH build cleanup * simplify SSH cleanup --- Jenkinsfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index d2eeaa1600..a5dfa86fb8 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -12,13 +12,13 @@ def retryWithDelay(int maxRetries, int delay, Closure body) { def device(String ip, String step_label, String cmd) { withCredentials([file(credentialsId: 'id_rsa', variable: 'key_file')]) { def ssh_cmd = """ -ssh -o ControlMaster=auto -o ControlPath=/tmp/ssh_control_%C -o ControlPersist=yes -o ConnectTimeout=5 -o ServerAliveInterval=5 -o ServerAliveCountMax=12 -o BatchMode=yes -o StrictHostKeyChecking=no -i ${key_file} 'comma@${ip}' exec /usr/bin/bash <<'END' +ssh -o ControlMaster=no -o ControlPath=none -o ConnectTimeout=5 -o ServerAliveInterval=5 -o ServerAliveCountMax=12 -o BatchMode=yes -o StrictHostKeyChecking=no -i ${key_file} 'comma@${ip}' exec setpriv --pdeathsig HUP /usr/bin/bash <<'END' set -e export TERM=xterm-256color -shopt -s huponexit # kill all child processes when the shell exits +trap 'kill 0' HUP # stop this process group on SSH disconnect export CI=1 export PYTHONWARNINGS=error @@ -69,7 +69,8 @@ export LD_LIBRARY_PATH="\$(python -c 'import ffmpeg; print(ffmpeg.LIB_DIR)'):/us ln -snf ${env.TEST_DIR} /data/pythonpath cd ${env.TEST_DIR} || true -time ${cmd} +time ( ${cmd} ) & +wait \$! END""" sh script: ssh_cmd, label: step_label From 0cf294d85fbaffd94a83f08536cf7a1bb3e75c80 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Fri, 11 Sep 2026 20:13:00 -0700 Subject: [PATCH 072/270] ui: add descriptions for settings (#38867) --- .../ui/mici/layouts/settings/developer.py | 39 ++++---- .../ui/mici/layouts/settings/device.py | 17 ++-- .../mici/layouts/settings/network/__init__.py | 8 +- .../settings/network/network_layout.py | 14 ++- .../ui/mici/layouts/settings/software.py | 7 +- .../ui/mici/layouts/settings/toggles.py | 34 +++++-- openpilot/selfdrive/ui/mici/widgets/button.py | 92 +++++++++++-------- openpilot/selfdrive/ui/mici/widgets/dialog.py | 27 ++++++ openpilot/system/ui/widgets/__init__.py | 19 ++++ 9 files changed, 176 insertions(+), 81 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py index 2d7dc0899d..0b52e48bff 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py @@ -23,8 +23,6 @@ class AlphaLongConfirmPage(NavScroller): GreyBigButton("", "WARNING: alpha longitudinal control may disable Automatic Emergency Braking (AEB)"), GreyBigButton("", "On this car, openpilot defaults to the stock system's built-in ACC."), GreyBigButton("", "Enabling this will switch to openpilot longitudinal control."), - GreyBigButton("", "Using Experimental mode is recommended with openpilot longitudinal control alpha."), - GreyBigButton("", "Changing this setting will restart openpilot if the car is powered on."), accept, ]) @@ -63,28 +61,31 @@ class DeveloperLayoutMici(NavScroller): txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 56, 64) github_username = ui_state.params.get("GithubUsername") or "" - self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh) + self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh, + description="Grant SSH access to all public keys in your GitHub settings. Only enter your own username.") self._ssh_keys_btn.set_click_callback(ssh_keys_callback) # adb, ssh, ssh keys, debug mode, joystick debug mode, longitudinal maneuver mode, ip address # ******** Main Scroller ******** - self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12)) - self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12)) - self._joystick_toggle = BigToggle("joystick debug mode", - initial_state=ui_state.params.get_bool("JoystickDebugMode"), - toggle_callback=self._on_joystick_debug_mode) - self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode", - initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"), - toggle_callback=self._on_long_maneuver_mode) - self._lat_maneuver_toggle = BigToggle("lateral maneuver mode", - initial_state=ui_state.params.get_bool("LateralManeuverMode"), - toggle_callback=self._on_lat_maneuver_mode) - self._alpha_long_toggle = BigToggle("alpha longitudinal", - initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"), - toggle_callback=self._on_alpha_long_enabled) + self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12), + description="Use Android Debug Bridge (ADB) over USB or the network.", title="enable ADB") + self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12), + description="Access the device remotely using your SSH keys.", title="enable SSH") + self._joystick_toggle = BigToggle("joystick debug\nmode", initial_state=ui_state.params.get_bool("JoystickDebugMode"), + toggle_callback=self._on_joystick_debug_mode, description="Control the car with a joystick for debugging.") + self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode", initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"), + toggle_callback=self._on_long_maneuver_mode, + description="Run longitudinal maneuvers for testing gas and brake control.") + self._lat_maneuver_toggle = BigToggle("lateral maneuver mode", initial_state=ui_state.params.get_bool("LateralManeuverMode"), + toggle_callback=self._on_lat_maneuver_mode, + description="Run lateral maneuvers for testing steering control.") + self._alpha_long_toggle = BigToggle("alpha longitudinal", initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"), + toggle_callback=self._on_alpha_long_enabled, + description="Use alpha openpilot longitudinal control instead of stock ACC. This may disable Automatic Emergency " + + "Braking (AEB).") self._debug_mode_toggle = BigParamControl("ui debug mode", "ShowDebugInfo", - toggle_callback=lambda checked: (gui_app.set_show_touches(checked), - gui_app.set_show_fps(checked))) + toggle_callback=lambda checked: (gui_app.set_show_touches(checked), gui_app.set_show_fps(checked)), + description="Show touch locations and the UI frame rate.") self._scroller.add_widgets([ self._adb_toggle, diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device.py b/openpilot/selfdrive/ui/mici/layouts/settings/device.py index 6038ff7d35..c038f5fddc 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device.py @@ -1,6 +1,7 @@ import os import pyray as rl from collections.abc import Callable +from typing import Union from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params @@ -77,15 +78,16 @@ def _engaged_confirmation_click(callback: Callable, action_text: str, icon: rl.T class EngagedConfirmationCircleButton(BigCircleButton): def __init__(self, title: str, icon: rl.Texture, callback: Callable[[], None], exit_on_confirm: bool = True, - red: bool = False, icon_offset: tuple[int, int] = (0, 0)): - super().__init__(icon, red, icon_offset) + red: bool = False, icon_offset: tuple[int, int] = (0, 0), *, description: str = ""): + super().__init__(icon, red, icon_offset, description=description, title=title) self.set_click_callback(lambda: _engaged_confirmation_click(callback, title, icon, exit_on_confirm=exit_on_confirm, red=red)) class EngagedConfirmationButton(BigButton): def __init__(self, text: str, action_text: str, icon: rl.Texture, callback: Callable[[], None], - exit_on_confirm: bool = True, red: bool = False): - super().__init__(text, "", icon) + exit_on_confirm: bool = True, red: bool = False, *, description: str = "", + description_icon: Union[rl.Texture, None] = None): + super().__init__(text, "", icon, description=description, description_icon=description_icon) self.set_click_callback(lambda: _engaged_confirmation_click(callback, action_text, icon, exit_on_confirm=exit_on_confirm, red=red)) @@ -177,7 +179,9 @@ class DeviceLayoutMici(NavScroller): params.put_bool("OnroadCycleRequested", True, block=True) reset_calibration_btn = EngagedConfirmationButton("reset calibration", "reset", gui_app.texture("icons_mici/settings/device/lkas.png", 122, 64), - reset_calibration_callback) + reset_calibration_callback, + description="Mount the device within 4° left or right and 5° up or 9° down. openpilot calibrates " + + "continuously; resetting is rarely needed. Resetting clears learned calibration.") reboot_btn = EngagedConfirmationCircleButton("reboot", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70), reboot_callback, exit_on_confirm=False) @@ -189,7 +193,8 @@ class DeviceLayoutMici(NavScroller): regulatory_btn = BigButton("regulatory info", "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64)) regulatory_btn.set_click_callback(self._on_regulatory) - cabin_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64)) + cabin_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64), + description="Preview the cabin camera to check driver monitoring visibility. The vehicle must be off.") cabin_cam_btn.set_click_callback(lambda: gui_app.push_widget(CabinCameraDialog())) cabin_cam_btn.set_enabled(lambda: ui_state.is_offroad()) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py index 89c106e236..bc297ed0e0 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -13,7 +13,7 @@ NetworkType = log.DeviceState.NetworkType class EsimNetworkButton(BigButton): - def __init__(self, cellular_manager: CellularManager): + def __init__(self, cellular_manager: CellularManager, *, description: str = ""): self._cellular_manager = cellular_manager self._cell_icons = { NetworkStrength.unknown: gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 64, 47), @@ -22,7 +22,7 @@ class EsimNetworkButton(BigButton): NetworkStrength.good: gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 64, 47), NetworkStrength.great: gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 64, 47), } - super().__init__("esim", "loading...", self._cell_icons[NetworkStrength.unknown], scroll=True) + super().__init__("esim", "loading...", self._cell_icons[NetworkStrength.unknown], scroll=True, description=description) def _update_state(self): super()._update_state() @@ -54,7 +54,7 @@ class EsimNetworkButton(BigButton): class WifiNetworkButton(BigButton): - def __init__(self, wifi_manager: WifiManager): + def __init__(self, wifi_manager: WifiManager, *, description: str = ""): self._wifi_manager = wifi_manager self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 28, 36) self._draw_lock = False @@ -64,7 +64,7 @@ class WifiNetworkButton(BigButton): self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 64, 47) self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 64, 47) - super().__init__("wi-fi", "not connected", self._wifi_slash_txt, scroll=True) + super().__init__("wi-fi", "not connected", self._wifi_slash_txt, scroll=True, description=description) def _update_state(self): super()._update_state() diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py index d991ed5806..4203336469 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -29,7 +29,8 @@ class NetworkLayoutMici(NavScroller): self._network_metered_btn.set_enabled(False) self._wifi_manager.set_tethering_active(checked) - self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback) + self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback, + description="Share the device’s internet connection through a Wi-Fi hotspot.") def tethering_password_callback(password: str): if password: @@ -59,7 +60,9 @@ class NetworkLayoutMici(NavScroller): # TODO: signal for current network metered type when changing networks, this is wrong until you press it once # TODO: disable when not connected - self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback) + self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback, + description="Metered prevents large uploads on this Wi-Fi connection. Default uses the network’s detected " + + "setting.") self._network_metered_btn.set_enabled(False) self._wifi_button = WifiNetworkButton(self._wifi_manager) @@ -76,14 +79,15 @@ class NetworkLayoutMici(NavScroller): # ******** Advanced settings ******** # ******** Roaming toggle ******** - self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming") + self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming", description="Allow cellular data roaming.") # ******** APN settings ******** - self._apn_btn = BigButton("apn settings", "edit") + self._apn_btn = BigButton("apn settings", "edit", + description="Set the access point name required by your cellular carrier. Leave blank for automatic configuration.") self._apn_btn.set_click_callback(self._edit_apn) # ******** Cellular metered toggle ******** - self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered") + self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered", description="Prevent large uploads over the cellular connection.") # Main scroller ---------------------------------- self._scroller.add_widgets([ diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 539ebaca11..7bf17713a3 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -242,7 +242,8 @@ class BranchSelectPage(NavScroller): class TargetBranchButton(BigButton): def __init__(self, check_update_btn: CheckUpdateButton): - super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "") + super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "", + description="Select the software branch to download on the next update check.") self._check_update_btn = check_update_btn self.set_click_callback(self._on_click) self.set_visible(not ui_state.params.get_bool("IsTestedBranch")) @@ -276,7 +277,9 @@ class SoftwareLayoutMici(NavScroller): uninstall_openpilot_btn = EngagedConfirmationButton("uninstall openpilot", "uninstall", gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64), - uninstall_openpilot_callback, exit_on_confirm=False) + uninstall_openpilot_callback, exit_on_confirm=False, + description="Remove openpilot from this device.", + description_icon=gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64)) check_update_btn = CheckUpdateButton() self._scroller.add_widgets([ diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index 4d5113448d..40a6188579 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -41,15 +41,33 @@ class TogglesLayoutMici(NavScroller): def __init__(self): super().__init__() - self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"]) - self._experimental_btn = BigToggle("experimental mode", initial_state=ui_state.params.get_bool("ExperimentalMode"), - toggle_callback=self._on_experimental_mode) + self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"], + description="Standard is recommended.\n" + + "Aggressive follows closer, with firmer gas and braking.\n" + + "Relaxed leaves more space.\n" + + "Use the steering wheel distance button on supported cars.") + self._experimental_btn = BigToggle("experimental mode", description_icon=gui_app.texture("icons_mici/experimental_mode.png", 64, 64), + initial_state=ui_state.params.get_bool("ExperimentalMode"), toggle_callback=self._on_experimental_mode, + description="Let the driving model control gas and brakes.\n" + + "Includes stopping for red lights and stop signs.\n" + + "Set speed is a maximum, not a target.\n" + + "These are alpha features. Expect mistakes.\n" + + "The path colors show acceleration and braking.") is_metric_toggle = BigParamControl("use metric units", "IsMetric") - ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled") - always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM") - record_front = BigParamControl("record & upload cabin camera", "RecordFront", toggle_callback=restart_needed_callback) - record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback) - enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback) + ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled", + description="Warn when you drift across a detected lane line.\n" + + "Only above 31 mph (50 km/h), with no turn signal.") + always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM", description="Monitor the driver even when openpilot is not engaged.") + record_front = BigParamControl("record & upload cabin camera", "RecordFront", + description_icon=gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64), + toggle_callback=restart_needed_callback, description="Upload cabin camera data to help improve driver monitoring.") + record_mic = BigParamControl("record & upload mic audio", "RecordAudio", description_icon=gui_app.texture("icons_mici/microphone.png", 64, 64), + toggle_callback=restart_needed_callback, + description="Record microphone audio while driving.\n" + + "Audio is included in dashcam videos in comma connect.") + enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback, + description="Enable to use openpilot driver assistance.\n" + + "Disable to use your car's stock driver assistance.") self._scroller.add_widgets([ self._personality_toggle, diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index 59a3f95191..4f395ae9bc 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -29,9 +29,41 @@ class ScrollState(Enum): POST_SCROLL = 2 -class BigCircleButton(Widget): - def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0)): +class BaseButton(Widget): + def __init__(self, description: str, title: str, icon: Union[rl.Texture, None] = None): super().__init__() + self._shake_start: float | None = None + if description: + # Dialogs also use buttons; import lazily to avoid a circular import. + from openpilot.selfdrive.ui.mici.widgets.dialog import SettingDescriptionDialog + self.set_long_press_callback(lambda: gui_app.push_widget(SettingDescriptionDialog(title, description, icon))) + else: + self.set_long_press_callback(self.trigger_shake) + + def trigger_shake(self): + self._shake_start = rl.get_time() + + @property + def _shake_offset(self) -> float: + SHAKE_DURATION = 0.5 + SHAKE_AMPLITUDE = 24.0 + SHAKE_FREQUENCY = 32.0 + if self._shake_start is None: + return 0.0 + t = rl.get_time() - self._shake_start + if t > SHAKE_DURATION: + return 0.0 + decay = 1.0 - t / SHAKE_DURATION + return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY) + + def set_position(self, x: float, y: float) -> None: + super().set_position(x + self._shake_offset, y) + +class BigCircleButton(BaseButton): + def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0), + *, description: str = "", + description_icon: Union[rl.Texture, None] = None, title: str = ""): + super().__init__(description, title, description_icon or icon) self._red = red self._icon_offset = icon_offset @@ -73,8 +105,9 @@ class BigCircleButton(Widget): class BigCircleToggle(BigCircleButton): - def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0)): - super().__init__(icon, False, icon_offset=icon_offset) + def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0), + *, description: str = "", description_icon: Union[rl.Texture, None] = None, title: str = ""): + super().__init__(icon, False, icon_offset=icon_offset, description=description, description_icon=description_icon, title=title) self._toggle_callback = toggle_callback # State @@ -103,14 +136,16 @@ class BigCircleToggle(BigCircleButton): 0, 1.0, rl.WHITE) -class BigButton(Widget): +class BigButton(BaseButton): LABEL_HORIZONTAL_PADDING = 40 LABEL_VERTICAL_PADDING = 23 # visually matches 30 in figma """A lightweight stand-in for the Qt BigButton, drawn & updated each frame.""" - def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False): - super().__init__() + def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False, + *, description: str = "", + description_icon: Union[rl.Texture, None] = None): + super().__init__(description, text, description_icon or icon or None) self.set_rect(rl.Rectangle(0, 0, 402, 180)) self.text = text self.value = value @@ -119,7 +154,6 @@ class BigButton(Widget): self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) self._click_delay = 0.075 - self._shake_start: float | None = None self._grow_animation_until: float | None = None self._rotate_icon_t: float | None = None @@ -187,28 +221,9 @@ class BigButton(Widget): def get_text(self): return self.text - def trigger_shake(self): - self._shake_start = rl.get_time() - def trigger_grow_animation(self, duration: float = 0.65): self._grow_animation_until = rl.get_time() + duration - @property - def _shake_offset(self) -> float: - SHAKE_DURATION = 0.5 - SHAKE_AMPLITUDE = 24.0 - SHAKE_FREQUENCY = 32.0 - if self._shake_start is None: - return 0.0 - t = rl.get_time() - self._shake_start - if t > SHAKE_DURATION: - return 0.0 - decay = 1.0 - t / SHAKE_DURATION - return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY) - - def set_position(self, x: float, y: float) -> None: - super().set_position(x + self._shake_offset, y) - def _handle_background(self) -> tuple[rl.Texture, float, float, float]: if self._grow_animation_until is not None: if rl.get_time() >= self._grow_animation_until: @@ -272,8 +287,10 @@ class BigButton(Widget): class BigToggle(BigButton): - def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None): - super().__init__(text, value, "") + def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None, + *, description: str = "", + description_icon: Union[rl.Texture, None] = None): + super().__init__(text, value, "", description=description, description_icon=description_icon) self._checked = initial_state self._toggle_callback = toggle_callback @@ -308,8 +325,8 @@ class BigToggle(BigButton): class BigMultiToggle(BigToggle): def __init__(self, text: str, options: list[str], toggle_callback: Callable | None = None, - select_callback: Callable | None = None): - super().__init__(text, "", toggle_callback=toggle_callback) + select_callback: Callable | None = None, *, description: str = "", description_icon: Union[rl.Texture, None] = None): + super().__init__(text, "", toggle_callback=toggle_callback, description=description, description_icon=description_icon) assert len(options) > 0 self._options = options self._select_callback = select_callback @@ -374,9 +391,9 @@ class GreyBigButton(BigButton): class BigMultiParamToggle(BigMultiToggle): def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable | None = None, - select_callback: Callable | None = None): + select_callback: Callable | None = None, *, description: str = "", description_icon: Union[rl.Texture, None] = None): assert Params is not None - super().__init__(text, options, toggle_callback, select_callback) + super().__init__(text, options, toggle_callback, select_callback, description=description, description_icon=description_icon) self._param = param self._params = Params() @@ -392,9 +409,10 @@ class BigMultiParamToggle(BigMultiToggle): class BigParamControl(BigToggle): - def __init__(self, text: str, param: str, toggle_callback: Callable | None = None): + def __init__(self, text: str, param: str, toggle_callback: Callable | None = None, *, description: str = "", + description_icon: Union[rl.Texture, None] = None): assert Params is not None - super().__init__(text, "", toggle_callback=toggle_callback) + super().__init__(text, "", toggle_callback=toggle_callback, description=description, description_icon=description_icon) self.param = param self.params = Params() self.set_checked(self.params.get_bool(self.param, False)) @@ -410,9 +428,9 @@ class BigParamControl(BigToggle): # TODO: param control base class class BigCircleParamControl(BigCircleToggle): def __init__(self, icon: rl.Texture, param: str, toggle_callback: Callable | None = None, - icon_offset: tuple[int, int] = (0, 0)): + icon_offset: tuple[int, int] = (0, 0), *, description: str = "", description_icon: Union[rl.Texture, None] = None, title: str = ""): assert Params is not None - super().__init__(icon, toggle_callback, icon_offset=icon_offset) + super().__init__(icon, toggle_callback, icon_offset=icon_offset, description=description, description_icon=description_icon, title=title) self._param = param self.params = Params() self.set_checked(self.params.get_bool(self._param, False)) diff --git a/openpilot/selfdrive/ui/mici/widgets/dialog.py b/openpilot/selfdrive/ui/mici/widgets/dialog.py index c023369933..d5da6178f1 100644 --- a/openpilot/selfdrive/ui/mici/widgets/dialog.py +++ b/openpilot/selfdrive/ui/mici/widgets/dialog.py @@ -1,9 +1,11 @@ import abc import math +import re import pyray as rl from typing import Union from collections.abc import Callable from openpilot.system.ui.widgets.nav_widget import NavWidget +from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.mici_keyboard import MiciKeyboard from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -37,6 +39,31 @@ class BigDialog(BigDialogBase): )) +class SettingDescriptionDialog(NavScroller): + def __init__(self, title: str, description: str, icon: Union[rl.Texture, None] = None): + super().__init__() + cards = [GreyBigButton(title, "scroll for details", icon or gui_app.texture("icons_mici/setup/green_info.png", 64, 64))] + # Explicit lines are authored cards; otherwise prefer sentence boundaries. + paragraphs = description.splitlines() if "\n" in description else re.split(r"(?<=[.!?])\s+", description.strip()) + # Measure each card so longer text still fits with the actual font and padding. + for sentence in paragraphs: + card = GreyBigButton("", "") + words: list[str] = [] + for word in sentence.split(): + card.set_value(" ".join([*words, word])) + height = card._sub_label.get_content_height(card._subtitle_width_hint()) + if words and height > card.rect.height - 2 * card.LABEL_VERTICAL_PADDING: + card.set_value(" ".join(words)) + cards.append(card) + card = GreyBigButton("", "") + words = [] + words.append(word) + if words: + card.set_value(" ".join(words)) + cards.append(card) + self._scroller.add_widgets(cards) + + class BigConfirmationDialog(BigDialogBase): def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None], exit_on_confirm: bool = True, red: bool = False): diff --git a/openpilot/system/ui/widgets/__init__.py b/openpilot/system/ui/widgets/__init__.py index 4e13920d60..e2e44c2543 100644 --- a/openpilot/system/ui/widgets/__init__.py +++ b/openpilot/system/ui/widgets/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc +import time import pyray as rl from enum import IntEnum from typing import Protocol, TypeVar @@ -51,6 +52,8 @@ class Widget(abc.ABC): self._click_delay: float | None = None # seconds to hold is_pressed after release self._click_release_time: float | None = None self._click_callback: Callable[[], None] | None = None + self._long_press_callback: Callable[[], None] | None = None + self._press_started: list[float | None] = [None] * MAX_TOUCH_SLOTS self._multi_touch = False self.__was_awake = True @@ -92,6 +95,9 @@ class Widget(abc.ABC): """Set a callback to be called when the widget is clicked.""" self._click_callback = click_callback + def set_long_press_callback(self, callback: Callable[[], None]) -> None: + self._long_press_callback = callback + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: """Set a callback to determine if the widget can be clicked.""" self._touch_valid_callback = touch_callback @@ -136,6 +142,7 @@ class Widget(abc.ABC): self._process_mouse_events() else: # TODO: ideally we emit release events when going disabled + self._press_started = [None] * MAX_TOUCH_SLOTS self.__is_pressed = [False] * MAX_TOUCH_SLOTS self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS @@ -160,6 +167,7 @@ class Widget(abc.ABC): # Allows touch to leave the rect and come back in focus if mouse did not release if mouse_event.left_pressed and touch_valid: if mouse_in_rect: + self._press_started[mouse_event.slot] = mouse_event.t self._handle_mouse_press(mouse_event.pos) self.__is_pressed[mouse_event.slot] = True self.__tracking_is_pressed[mouse_event.slot] = True @@ -185,9 +193,20 @@ class Widget(abc.ABC): # Mouse/touch left our rect but may come back into focus later elif not mouse_in_rect: + self._press_started[mouse_event.slot] = None self.__is_pressed[mouse_event.slot] = False self._handle_mouse_event(mouse_event) + if self._long_press_callback is not None and touch_valid: + for slot, started in enumerate(self._press_started): + if started is not None and self.__is_pressed[slot] and time.monotonic() - started >= 0.45: + # Clear tracking before opening help so release cannot activate a toggle or action. + self._press_started[slot] = None + self.__is_pressed[slot] = False + self.__tracking_is_pressed[slot] = False + self._long_press_callback() + break + def _layout(self) -> None: """Optionally lay out child widgets separately. This is called before rendering.""" From dd226ac79191357910c91508fbabcc872a358e79 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Sat, 12 Sep 2026 12:41:14 -0700 Subject: [PATCH 073/270] update chestnut fw in CI (#38876) Update Chestnut firmware before CI build --- openpilot/selfdrive/test/chestnut.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpilot/selfdrive/test/chestnut.sh b/openpilot/selfdrive/test/chestnut.sh index 8ccc6722de..fdb1967429 100755 --- a/openpilot/selfdrive/test/chestnut.sh +++ b/openpilot/selfdrive/test/chestnut.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -e +sudo python3 openpilot/system/hardware/chestnut/flash.py + TARGET=openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest rm -f "$TARGET" SCONSFLAGS="-j4" ./openpilot/system/manager/build.py From f1ba169fb5a87d9ff664ed2dd6add68fb29ec26a Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sat, 12 Sep 2026 13:45:51 -0700 Subject: [PATCH 074/270] remove old profiler setups --- tools/scripts/profiling/clpeak/.gitignore | 1 - tools/scripts/profiling/clpeak/build.sh | 22 ----------------- .../profiling/clpeak/run_continuously.patch | 13 ---------- tools/scripts/profiling/palanteer/.gitignore | 2 -- tools/scripts/profiling/palanteer/setup.sh | 24 ------------------- tools/scripts/profiling/perfetto/.gitignore | 7 ------ tools/scripts/profiling/perfetto/build.sh | 11 --------- tools/scripts/profiling/perfetto/copy.sh | 6 ----- tools/scripts/profiling/perfetto/record.sh | 8 ------- tools/scripts/profiling/perfetto/server.sh | 6 ----- tools/scripts/profiling/perfetto/traces.sh | 5 ---- tools/scripts/profiling/py-spy/profile.sh | 21 ---------------- tools/scripts/profiling/snapdragon/.gitignore | 1 - tools/scripts/profiling/snapdragon/README.md | 13 ---------- .../profiling/snapdragon/setup-agnos.sh | 7 ------ .../profiling/snapdragon/setup-profiler.sh | 14 ----------- 16 files changed, 161 deletions(-) delete mode 100644 tools/scripts/profiling/clpeak/.gitignore delete mode 100755 tools/scripts/profiling/clpeak/build.sh delete mode 100644 tools/scripts/profiling/clpeak/run_continuously.patch delete mode 100644 tools/scripts/profiling/palanteer/.gitignore delete mode 100755 tools/scripts/profiling/palanteer/setup.sh delete mode 100644 tools/scripts/profiling/perfetto/.gitignore delete mode 100755 tools/scripts/profiling/perfetto/build.sh delete mode 100755 tools/scripts/profiling/perfetto/copy.sh delete mode 100755 tools/scripts/profiling/perfetto/record.sh delete mode 100755 tools/scripts/profiling/perfetto/server.sh delete mode 100755 tools/scripts/profiling/perfetto/traces.sh delete mode 100755 tools/scripts/profiling/py-spy/profile.sh delete mode 100644 tools/scripts/profiling/snapdragon/.gitignore delete mode 100644 tools/scripts/profiling/snapdragon/README.md delete mode 100755 tools/scripts/profiling/snapdragon/setup-agnos.sh delete mode 100755 tools/scripts/profiling/snapdragon/setup-profiler.sh diff --git a/tools/scripts/profiling/clpeak/.gitignore b/tools/scripts/profiling/clpeak/.gitignore deleted file mode 100644 index d575c207c1..0000000000 --- a/tools/scripts/profiling/clpeak/.gitignore +++ /dev/null @@ -1 +0,0 @@ -clpeak/ diff --git a/tools/scripts/profiling/clpeak/build.sh b/tools/scripts/profiling/clpeak/build.sh deleted file mode 100755 index 5206ed0c45..0000000000 --- a/tools/scripts/profiling/clpeak/build.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" -cd $DIR - -if [ ! -d "$DIR/clpeak" ]; then - git clone https://github.com/krrishnarraj/clpeak.git - - cd clpeak - git fetch - git checkout ec2d3e70e1abc7738b81f9277c7af79d89b2133b - git reset --hard origin/master - git submodule update --init --recursive --remote - - git apply ../run_continuously.patch -fi - -cd clpeak -mkdir build || true -cd build -cmake .. -cmake --build . diff --git a/tools/scripts/profiling/clpeak/run_continuously.patch b/tools/scripts/profiling/clpeak/run_continuously.patch deleted file mode 100644 index 075c65bd2a..0000000000 --- a/tools/scripts/profiling/clpeak/run_continuously.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/src/clpeak.cpp b/src/clpeak.cpp -index 8cb192b..b6fe6f5 100644 ---- a/src/clpeak.cpp -+++ b/src/clpeak.cpp -@@ -47,7 +47,7 @@ int clPeak::runAll() - - log->xmlOpenTag("clpeak"); - log->xmlAppendAttribs("os", OS_NAME); -- for (size_t p = 0; p < platforms.size(); p++) -+ for (size_t p = 0; p < platforms.size(); (p+1 % platforms.size())) - { - if (forcePlatform && (p != specifiedPlatform)) - continue; diff --git a/tools/scripts/profiling/palanteer/.gitignore b/tools/scripts/profiling/palanteer/.gitignore deleted file mode 100644 index 158e7b0759..0000000000 --- a/tools/scripts/profiling/palanteer/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -palanteer/ -viewer diff --git a/tools/scripts/profiling/palanteer/setup.sh b/tools/scripts/profiling/palanteer/setup.sh deleted file mode 100755 index 6f115dc86b..0000000000 --- a/tools/scripts/profiling/palanteer/setup.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash - -set -e - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" -cd $DIR - -if [ ! -d palanteer ]; then - git clone https://github.com/dfeneyrou/palanteer - pip install wheel - sudo apt install libunwind-dev libdw-dev -fi - -cd palanteer -git pull - -mkdir -p build -cd build -cmake .. -DCMAKE_BUILD_TYPE=Release -make -j$(nproc) - -pip install --force-reinstall python/dist/palanteer*.whl - -cp bin/palanteer $DIR/viewer diff --git a/tools/scripts/profiling/perfetto/.gitignore b/tools/scripts/profiling/perfetto/.gitignore deleted file mode 100644 index d86c89d462..0000000000 --- a/tools/scripts/profiling/perfetto/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -trace_* - -tracebox -trace_processor - -perfetto/ -configs/ diff --git a/tools/scripts/profiling/perfetto/build.sh b/tools/scripts/profiling/perfetto/build.sh deleted file mode 100755 index 24255ac3b0..0000000000 --- a/tools/scripts/profiling/perfetto/build.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -if [ ! -d perfetto ]; then - git clone https://android.googlesource.com/platform/external/perfetto/ -fi - -cd perfetto - -tools/install-build-deps --linux-arm -tools/gn gen --args='is_debug=false target_os="linux" target_cpu="arm64"' out/linux -tools/ninja -C out/linux tracebox traced traced_probes perfetto diff --git a/tools/scripts/profiling/perfetto/copy.sh b/tools/scripts/profiling/perfetto/copy.sh deleted file mode 100755 index b91d49a80b..0000000000 --- a/tools/scripts/profiling/perfetto/copy.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -DEST=tici:/data/openpilot/selfdrive/debug/profiling/perfetto - -scp -r perfetto/out/linux/tracebox $DEST -scp -r perfetto/test/configs $DEST diff --git a/tools/scripts/profiling/perfetto/record.sh b/tools/scripts/profiling/perfetto/record.sh deleted file mode 100755 index 9715a7e7e9..0000000000 --- a/tools/scripts/profiling/perfetto/record.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -cd $DIR - -OUT=trace_ -sudo ./tracebox -o $OUT --txt -c configs/scheduling.cfg -sudo chown $USER:$USER $OUT diff --git a/tools/scripts/profiling/perfetto/server.sh b/tools/scripts/profiling/perfetto/server.sh deleted file mode 100755 index 103ad9bd84..0000000000 --- a/tools/scripts/profiling/perfetto/server.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -curl -LO https://get.perfetto.dev/trace_processor -chmod +x ./trace_processor - -./trace_processor --httpd diff --git a/tools/scripts/profiling/perfetto/traces.sh b/tools/scripts/profiling/perfetto/traces.sh deleted file mode 100755 index 17ca89da72..0000000000 --- a/tools/scripts/profiling/perfetto/traces.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -DEST=tici:/data/openpilot/selfdrive/debug/profiling/perfetto - -scp tici:/data/openpilot/selfdrive/debug/profiling/perfetto/trace_* . diff --git a/tools/scripts/profiling/py-spy/profile.sh b/tools/scripts/profiling/py-spy/profile.sh deleted file mode 100755 index 2fdbe8d16e..0000000000 --- a/tools/scripts/profiling/py-spy/profile.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -set -e - -cd "$(dirname "$0")" - -# find process with name passed in (excluding this process) -for PID in $(pgrep -f $1); do - if [ "$PID" != "$$" ]; then - ps -p $PID -o args - TRACE_PID=$PID - break - fi -done - -if [ -z "$TRACE_PID" ]; then - echo "could not find PID for $1" - exit 1 -fi - -sudo env PATH=$PATH py-spy record -d 5 -o /tmp/perf$TRACE_PID.svg -p $TRACE_PID && -google-chrome /tmp/perf$TRACE_PID.svg diff --git a/tools/scripts/profiling/snapdragon/.gitignore b/tools/scripts/profiling/snapdragon/.gitignore deleted file mode 100644 index 80078842a0..0000000000 --- a/tools/scripts/profiling/snapdragon/.gitignore +++ /dev/null @@ -1 +0,0 @@ -SnapdragonProfiler/ \ No newline at end of file diff --git a/tools/scripts/profiling/snapdragon/README.md b/tools/scripts/profiling/snapdragon/README.md deleted file mode 100644 index 383d83ba9b..0000000000 --- a/tools/scripts/profiling/snapdragon/README.md +++ /dev/null @@ -1,13 +0,0 @@ -snapdragon profiler --------- - - -* download from https://developer.qualcomm.com/software/snapdragon-profiler/tools-archive (need a qc developer account) - * choose v2021.5 (verified working with 24.04 dev environment) -* unzip to openpilot/selfdrive/debug/profiling/snapdragon/SnapdragonProfiler -* run ```./setup-profiler.sh``` -* run ```./setup-agnos.sh``` -* run ```openpilot/selfdrive/debug/adb.sh``` on device -* run the ```adb connect xxx``` command that was given to you on local pc -* cd to SnapdragonProfiler and run ```./run_sdp.sh``` -* connect to device -> choose device you just setup diff --git a/tools/scripts/profiling/snapdragon/setup-agnos.sh b/tools/scripts/profiling/snapdragon/setup-agnos.sh deleted file mode 100755 index 9a781bf3ce..0000000000 --- a/tools/scripts/profiling/snapdragon/setup-agnos.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -# TODO: there's probably a better way to do this - -cd SnapdragonProfiler/service -mv android real_android -ln -s agl/ android diff --git a/tools/scripts/profiling/snapdragon/setup-profiler.sh b/tools/scripts/profiling/snapdragon/setup-profiler.sh deleted file mode 100755 index b97c1ae688..0000000000 --- a/tools/scripts/profiling/snapdragon/setup-profiler.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash - -# install depends -sudo apt update -sudo apt-get install libc++1 libc++abi1 default-jre android-tools-adb gtk-sharp2 - -# setup mono -sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF -sudo apt install apt-transport-https ca-certificates -echo "deb https://download.mono-project.com/repo/ubuntu stable-xenial main" | sudo tee /etc/apt/sources.list.d/mono-official-stable.list -sudo apt update -sudo apt-get install -y mono-complete - -echo "Setup successful, you should now be able to run the profiler with cd SnapdragonProfiler and ./run_sdp.sh" From 5cad99bf7a7e0013e44e3e243cd7c5636dc2d292 Mon Sep 17 00:00:00 2001 From: David <49467229+TheSecurityDev@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:06:20 -0500 Subject: [PATCH 075/270] fix(tools): allow spaces in workspace path for op.sh (#38839) --- tools/op.sh | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tools/op.sh b/tools/op.sh index f17714e620..183b4b1012 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -73,11 +73,11 @@ function op_get_openpilot_dir() { function op_install_post_commit() { op_get_openpilot_dir - if [[ ! -d $OPENPILOT_ROOT/.git/hooks/post-commit.d ]]; then - mkdir $OPENPILOT_ROOT/.git/hooks/post-commit.d - mv $OPENPILOT_ROOT/.git/hooks/post-commit $OPENPILOT_ROOT/.git/hooks/post-commit.d 2>/dev/null || true + if [[ ! -d "$OPENPILOT_ROOT/.git/hooks/post-commit.d" ]]; then + mkdir "$OPENPILOT_ROOT/.git/hooks/post-commit.d" + mv "$OPENPILOT_ROOT/.git/hooks/post-commit" "$OPENPILOT_ROOT/.git/hooks/post-commit.d" 2>/dev/null || true fi - cd $OPENPILOT_ROOT/.git/hooks + cd "$OPENPILOT_ROOT/.git/hooks" ln -sf ../../scripts/post-commit post-commit } @@ -103,7 +103,7 @@ function op_check_git() { fi echo "Checking for git lfs files..." - if [[ $(file -b $OPENPILOT_ROOT/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx) == "data" ]]; then + if [[ $(file -b "$OPENPILOT_ROOT/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx") == "data" ]]; then echo -e " ↳ [${GREEN}✔${NC}] git lfs files found." else echo -e " ↳ [${RED}✗${NC}] git lfs files not found! Run 'git lfs pull'" @@ -112,7 +112,7 @@ function op_check_git() { echo "Checking for git submodules..." for name in $(git config --file .gitmodules --get-regexp path | awk '{ print $2 }' | tr '\n' ' '); do - if [[ -z $(ls $OPENPILOT_ROOT/$name) ]]; then + if [[ -z $(ls "$OPENPILOT_ROOT/$name") ]]; then echo -e " ↳ [${RED}✗${NC}] git submodule $name not found! Run 'git submodule update --init --recursive'" return 1 fi @@ -134,10 +134,10 @@ function op_check_os() { function op_check_venv() { echo "Checking for venv..." - if [[ -f $OPENPILOT_ROOT/.venv/bin/activate ]]; then + if [[ -f "$OPENPILOT_ROOT/.venv/bin/activate" ]]; then echo -e " ↳ [${GREEN}✔${NC}] venv detected." else - echo -e " ↳ [${RED}✗${NC}] Can't activate venv in $OPENPILOT_ROOT. Assuming global env!" + echo -e " ↳ [${RED}✗${NC}] Can't activate venv in '$OPENPILOT_ROOT'. Assuming global env!" fi } @@ -147,7 +147,7 @@ function op_before_cmd() { fi op_get_openpilot_dir - cd $OPENPILOT_ROOT + cd "$OPENPILOT_ROOT" result="$((op_check_openpilot_dir ) 2>&1)" || (echo -e "$result" && return 1) result="${result}\n$(( op_check_git ) 2>&1)" || (echo -e "$result" && return 1) @@ -176,7 +176,7 @@ EOF echo -e " ↳ [${GREEN}✔${NC}] op installed successfully. Open a new shell to use it." op_get_openpilot_dir - cd $OPENPILOT_ROOT + cd "$OPENPILOT_ROOT" op_check_openpilot_dir op_check_os @@ -195,7 +195,7 @@ EOF echo "Installing dependencies..." st="$(date +%s)" SETUP_SCRIPT="tools/setup_dependencies.sh" - if ! $OPENPILOT_ROOT/$SETUP_SCRIPT; then + if ! "$OPENPILOT_ROOT/$SETUP_SCRIPT"; then echo -e " ↳ [${RED}✗${NC}] Dependencies installation failed!" return 1 fi @@ -230,7 +230,7 @@ function op_auth() { function op_activate_venv() { # bash 3.2 can't handle this without the 'set +e' set +e - source $OPENPILOT_ROOT/.venv/bin/activate &> /dev/null || true + source "$OPENPILOT_ROOT/.venv/bin/activate" &> /dev/null || true set -e # persist venv on PATH across GitHub Actions steps @@ -242,18 +242,18 @@ function op_activate_venv() { function op_venv() { op_before_cmd - if [[ ! -f $OPENPILOT_ROOT/.venv/bin/activate ]]; then - echo -e "No venv found in $OPENPILOT_ROOT" + if [[ ! -f "$OPENPILOT_ROOT/.venv/bin/activate" ]]; then + echo -e "No venv found in '$OPENPILOT_ROOT'" return 1 fi case $SHELL_NAME in "zsh") ZSHRC_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'tmp_zsh') - echo "source $RC_FILE; source $OPENPILOT_ROOT/.venv/bin/activate" >> $ZSHRC_DIR/.zshrc + echo "source \"$RC_FILE\"; source \"$OPENPILOT_ROOT/.venv/bin/activate\"" >> $ZSHRC_DIR/.zshrc ZDOTDIR=$ZSHRC_DIR zsh ;; *) - bash --rcfile <(echo "source $RC_FILE; source $OPENPILOT_ROOT/.venv/bin/activate") ;; + bash --rcfile <(echo "source \"$RC_FILE\"; source \"$OPENPILOT_ROOT/.venv/bin/activate\"") ;; esac } From ad5c0b946d4c2200e532e9a4f36894a46d1d39dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20R=C4=85czy?= Date: Sat, 12 Sep 2026 14:13:08 -0700 Subject: [PATCH 076/270] tools: optimize route parsing (#38714) --- openpilot/tools/lib/route.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpilot/tools/lib/route.py b/openpilot/tools/lib/route.py index 98334a06c8..94ed7c1fa6 100644 --- a/openpilot/tools/lib/route.py +++ b/openpilot/tools/lib/route.py @@ -206,7 +206,8 @@ class Segment: class RouteName: def __init__(self, name_str: str): self._name_str = name_str - delim = next(c for c in self._name_str if c in ("|", "/")) + pipe, slash = name_str.find("|"), name_str.find("/") + delim = "|" if slash == -1 or 0 <= pipe < slash else "/" self._dongle_id, self._time_str = self._name_str.split(delim) assert len(self._dongle_id) == 16, self._name_str From 64b4dbf115c274ff874c38e1f3c903eea3f8d8ac Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sat, 12 Sep 2026 14:14:39 -0700 Subject: [PATCH 077/270] add shellcheck-like static analysis (#38877) --- launch_chffrplus.sh | 20 +-- openpilot/selfdrive/assets/prep-svg.sh | 4 +- openpilot/selfdrive/test/scons_build_test.sh | 4 +- openpilot/selfdrive/test/setup_device_ci.sh | 28 ++-- .../ui/translations/auto_translate.sh | 2 +- .../system/camerad/test/stress_restart.sh | 4 +- openpilot/tools/sim/launch_openpilot.sh | 2 +- scripts/apply-pr.sh | 4 +- scripts/checkout-pr.sh | 8 +- scripts/jenkins_loop_test.sh | 34 ++--- scripts/launch_corolla.sh | 2 +- scripts/lint/check_nomerge_comments.sh | 4 +- scripts/lint/check_shebang_format.sh | 6 +- scripts/lint/check_shell.py | 132 ++++++++++++++++++ scripts/lint/lint.sh | 55 +++++--- scripts/retry.sh | 4 +- tools/op.sh | 12 +- tools/release/build_release.sh | 14 +- tools/release/build_stripped.sh | 26 ++-- tools/release/check-dirty.sh | 2 +- tools/release/check-submodules.sh | 6 +- tools/scripts/adb_ssh.sh | 4 +- tools/setup.sh | 16 +-- tools/setup_dependencies.sh | 28 ++-- 24 files changed, 281 insertions(+), 140 deletions(-) create mode 100755 scripts/lint/check_shell.py diff --git a/launch_chffrplus.sh b/launch_chffrplus.sh index f30e03ca62..240bc3b648 100755 --- a/launch_chffrplus.sh +++ b/launch_chffrplus.sh @@ -18,21 +18,21 @@ function agnos_init { sudo chmod 660 /dev/adsprpc-smd /dev/ion /dev/kgsl-3d0 # Check if AGNOS update is required - if [ $(< /VERSION) != "$AGNOS_VERSION" ]; then + if [ "$(< /VERSION)" != "$AGNOS_VERSION" ]; then AGNOS_PY="$DIR/openpilot/common/hardware/comma/agnos.py" MANIFEST="$DIR/openpilot/system/hardware/comma/agnos.json" - if $AGNOS_PY --verify $MANIFEST; then + if "$AGNOS_PY" --verify "$MANIFEST"; then sudo reboot fi while true; do - $DIR/openpilot/common/hardware/comma/updater $AGNOS_PY $MANIFEST + "$DIR/openpilot/common/hardware/comma/updater" "$AGNOS_PY" "$MANIFEST" done fi } function launch { # Remove orphaned git lock if it exists on boot - [ -f "$DIR/.git/index.lock" ] && rm -f $DIR/.git/index.lock + [ -f "$DIR/.git/index.lock" ] && rm -f "$DIR/.git/index.lock" # Check to see if there's a valid overlay-based update available. Conditions # are as follows: @@ -44,7 +44,7 @@ function launch { # that completed successfully and synced to disk. if [ -f "${DIR}/.overlay_init" ]; then - find ${DIR}/.git -newer ${DIR}/.overlay_init | grep -q '.' 2> /dev/null + find "${DIR}/.git" -newer "${DIR}/.overlay_init" | grep -q '.' 2> /dev/null if [ $? -eq 0 ]; then echo "${DIR} has been modified, skipping overlay update installation" else @@ -53,9 +53,9 @@ function launch { echo "Valid overlay update found, installing" LAUNCHER_LOCATION="${BASH_SOURCE[0]}" - mv $DIR /data/safe_staging/old_openpilot - mv "${STAGING_ROOT}/finalized" $DIR - cd $DIR + mv "$DIR" /data/safe_staging/old_openpilot + mv "${STAGING_ROOT}/finalized" "$DIR" + cd "$DIR" echo "Restarting launch script ${LAUNCHER_LOCATION}" unset AGNOS_VERSION @@ -69,7 +69,7 @@ function launch { fi # handle pythonpath - ln -sfn $(pwd) /data/pythonpath + ln -sfn "$(pwd)" /data/pythonpath export PYTHONPATH="$PWD" # submodule package symlinks for PYTHONPATH imports on device. @@ -90,7 +90,7 @@ function launch { # start manager cd openpilot/system/manager - if [ ! -f $DIR/prebuilt ]; then + if [ ! -f "$DIR/prebuilt" ]; then ./build.py fi ./manager.py diff --git a/openpilot/selfdrive/assets/prep-svg.sh b/openpilot/selfdrive/assets/prep-svg.sh index 567c17da32..ee6ba0a3ac 100755 --- a/openpilot/selfdrive/assets/prep-svg.sh +++ b/openpilot/selfdrive/assets/prep-svg.sh @@ -23,8 +23,8 @@ done # sudo apt install inkscape -for svg in $(find $DIR -type f | grep svg$); do - bunx svgo $svg --multipass --pretty --indent 2 +for svg in $(find "$DIR" -type f | grep svg$); do + bunx svgo "$svg" --multipass --pretty --indent 2 # convert to PNG png="${svg%.svg}.png" diff --git a/openpilot/selfdrive/test/scons_build_test.sh b/openpilot/selfdrive/test/scons_build_test.sh index 6f6eafcad1..dfdaf6189f 100755 --- a/openpilot/selfdrive/test/scons_build_test.sh +++ b/openpilot/selfdrive/test/scons_build_test.sh @@ -3,7 +3,7 @@ set -e SCRIPT_DIR=$(dirname "$0") BASEDIR=$(realpath "$SCRIPT_DIR/../../../") -cd $BASEDIR +cd "$BASEDIR" # tests that our build system's dependencies are configured properly, # needs a machine with lots of cores @@ -11,7 +11,7 @@ cd $BASEDIR # helpful commands: # scons -Q --tree=derived -cd $BASEDIR/opendbc_repo/ +cd "$BASEDIR/opendbc_repo/" scons --clean scons --no-cache --random if ! scons -q; then diff --git a/openpilot/selfdrive/test/setup_device_ci.sh b/openpilot/selfdrive/test/setup_device_ci.sh index 8558a38bc9..a1dd88dcf4 100755 --- a/openpilot/selfdrive/test/setup_device_ci.sh +++ b/openpilot/selfdrive/test/setup_device_ci.sh @@ -29,7 +29,7 @@ if [ -d /data/safe_staging/ ]; then fi CONTINUE_PATH="/data/continue.sh" -tee $CONTINUE_PATH << EOF +tee "$CONTINUE_PATH" << EOF #!/usr/bin/env bash sudo abctl --set_success @@ -54,7 +54,7 @@ done sleep infinity EOF -chmod +x $CONTINUE_PATH +chmod +x "$CONTINUE_PATH" export GIT_LFS_SKIP_SMUDGE=1 pull_lfs() { @@ -87,16 +87,16 @@ pull_lfs() { safe_checkout() { # completely clean TEST_DIR - cd $SOURCE_DIR + cd "$SOURCE_DIR" # cleanup orphaned locks find .git -type f -name "*.lock" -exec rm {} + git reset --hard - git fetch --no-tags --no-recurse-submodules -j4 --verbose --depth 1 origin $GIT_COMMIT + git fetch --no-tags --no-recurse-submodules -j4 --verbose --depth 1 origin "$GIT_COMMIT" find . -maxdepth 1 -not -path './.git' -not -name '.' -not -name '..' -exec rm -rf '{}' \; - git reset --hard $GIT_COMMIT - git checkout $GIT_COMMIT + git reset --hard "$GIT_COMMIT" + git checkout "$GIT_COMMIT" git clean -xdff git submodule sync git submodule foreach --recursive "git reset --hard && git clean -xdff" @@ -106,22 +106,22 @@ safe_checkout() { pull_lfs echo "git checkout done, t=$SECONDS" - du -hs $SOURCE_DIR $SOURCE_DIR/.git + du -hs "$SOURCE_DIR" "$SOURCE_DIR/.git" - rsync -a --delete $SOURCE_DIR $TEST_DIR + rsync -a --delete "$SOURCE_DIR" "$TEST_DIR" } unsafe_checkout() {( set -e # checkout directly in test dir, leave old build products - cd $TEST_DIR + cd "$TEST_DIR" # cleanup orphaned locks find .git -type f -name "*.lock" -exec rm {} + - git fetch --no-tags --no-recurse-submodules -j8 --verbose --depth 1 origin $GIT_COMMIT - git checkout --force --no-recurse-submodules $GIT_COMMIT - git reset --hard $GIT_COMMIT + git fetch --no-tags --no-recurse-submodules -j8 --verbose --depth 1 origin "$GIT_COMMIT" + git checkout --force --no-recurse-submodules "$GIT_COMMIT" + git reset --hard "$GIT_COMMIT" git clean -dff git submodule sync git submodule foreach --recursive "git reset --hard && git clean -df" @@ -135,7 +135,7 @@ export GIT_PACK_THREADS=8 # set up environment if [ ! -d "$SOURCE_DIR" ]; then - git clone https://github.com/commaai/openpilot.git $SOURCE_DIR + git clone https://github.com/commaai/openpilot.git "$SOURCE_DIR" fi if [ ! -z "$UNSAFE" ]; then @@ -152,7 +152,7 @@ else fi # submodule package symlinks for PYTHONPATH imports on device (same as launch_chffrplus.sh) -cd $TEST_DIR +cd "$TEST_DIR" ln -sfn msgq_repo/msgq msgq ln -sfn opendbc_repo/opendbc opendbc ln -sfn rednose_repo/rednose rednose diff --git a/openpilot/selfdrive/ui/translations/auto_translate.sh b/openpilot/selfdrive/ui/translations/auto_translate.sh index 7238426c75..858e60eb26 100755 --- a/openpilot/selfdrive/ui/translations/auto_translate.sh +++ b/openpilot/selfdrive/ui/translations/auto_translate.sh @@ -4,7 +4,7 @@ set -euo pipefail DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd)" ROOT="$DIR/../../../" -cd $DIR +cd "$DIR" ./update_translations.py command -v codex >/dev/null || { diff --git a/openpilot/system/camerad/test/stress_restart.sh b/openpilot/system/camerad/test/stress_restart.sh index 0445dcba79..5f0f2dd2f9 100755 --- a/openpilot/system/camerad/test/stress_restart.sh +++ b/openpilot/system/camerad/test/stress_restart.sh @@ -4,6 +4,6 @@ while :; do ./camerad & pid="$!" sleep 2 - kill -2 $pid - wait $pid + kill -2 "$pid" + wait "$pid" done diff --git a/openpilot/tools/sim/launch_openpilot.sh b/openpilot/tools/sim/launch_openpilot.sh index 392f365d03..813711dd79 100755 --- a/openpilot/tools/sim/launch_openpilot.sh +++ b/openpilot/tools/sim/launch_openpilot.sh @@ -18,4 +18,4 @@ SCRIPT_DIR=$(dirname "$0") OPENPILOT_DIR=$SCRIPT_DIR/../../ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" -cd $OPENPILOT_DIR/system/manager && exec ./manager.py +cd "$OPENPILOT_DIR/system/manager" && exec ./manager.py diff --git a/scripts/apply-pr.sh b/scripts/apply-pr.sh index ad0af46b49..f4a29ec087 100755 --- a/scripts/apply-pr.sh +++ b/scripts/apply-pr.sh @@ -6,6 +6,6 @@ if [ $# -eq 0 ]; then fi BASE="https://github.com/commaai/openpilot/pull/" -PR_NUM="$(echo $1 | grep -o -E '[0-9]+')" +PR_NUM="$(echo "$1" | grep -o -E '[0-9]+')" -curl -L $BASE/$PR_NUM.patch | git apply -3 +curl -L "$BASE/$PR_NUM.patch" | git apply -3 diff --git a/scripts/checkout-pr.sh b/scripts/checkout-pr.sh index eeba816d88..5f5dc8b1e3 100755 --- a/scripts/checkout-pr.sh +++ b/scripts/checkout-pr.sh @@ -7,10 +7,10 @@ if [ $# -eq 0 ]; then fi BASE="https://github.com/commaai/openpilot/pull/" -PR_NUM="$(echo $1 | grep -o -E '[0-9]+')" +PR_NUM="$(echo "$1" | grep -o -E '[0-9]+')" BRANCH=tmp-pr${PR_NUM} -git branch -D -f $BRANCH || true -git fetch -u -f origin pull/$PR_NUM/head:$BRANCH -git switch $BRANCH +git branch -D -f "$BRANCH" || true +git fetch -u -f origin "pull/$PR_NUM/head:$BRANCH" +git switch "$BRANCH" git reset --hard FETCH_HEAD diff --git a/scripts/jenkins_loop_test.sh b/scripts/jenkins_loop_test.sh index 8073f4668c..6cbdafd355 100755 --- a/scripts/jenkins_loop_test.sh +++ b/scripts/jenkins_loop_test.sh @@ -11,7 +11,7 @@ BRANCH="master" RUNS="20" COOKIE_JAR=/tmp/cookies -CRUMB=$(curl -s --cookie-jar $COOKIE_JAR 'https://jenkins.comma.life/crumbIssuer/api/xml?xpath=concat(//crumbRequestField,":",//crumb)') +CRUMB=$(curl -s --cookie-jar "$COOKIE_JAR" 'https://jenkins.comma.life/crumbIssuer/api/xml?xpath=concat(//crumbRequestField,":",//crumb)') FIRST_LOOP=1 @@ -25,13 +25,13 @@ function loop() { if [[ $FIRST_LOOP ]]; then TEMP_DIR=$(mktemp -d) - GIT_LFS_SKIP_SMUDGE=1 git clone --quiet -b $BRANCH --depth=1 --no-tags git@github.com:commaai/openpilot $TEMP_DIR - git -C $TEMP_DIR checkout --quiet -b $JENKINS_BRANCH - echo "TESTING: $(date)" >> $TEMP_DIR/testing_jenkins - git -C $TEMP_DIR add testing_jenkins - git -C $TEMP_DIR commit --quiet -m "testing" - git -C $TEMP_DIR push --quiet -f origin $JENKINS_BRANCH - rm -rf $TEMP_DIR + GIT_LFS_SKIP_SMUDGE=1 git clone --quiet -b "$BRANCH" --depth=1 --no-tags git@github.com:commaai/openpilot "$TEMP_DIR" + git -C "$TEMP_DIR" checkout --quiet -b "$JENKINS_BRANCH" + echo "TESTING: $(date)" >> "$TEMP_DIR/testing_jenkins" + git -C "$TEMP_DIR" add testing_jenkins + git -C "$TEMP_DIR" commit --quiet -m "testing" + git -C "$TEMP_DIR" push --quiet -f origin "$JENKINS_BRANCH" + rm -rf "$TEMP_DIR" FIRST_BUILD=1 echo '' echo 'waiting on Jenkins...' @@ -40,15 +40,15 @@ function loop() { FIRST_LOOP="" fi - FIRST_BUILD=$(curl -s $API_ROUTE/api/json | jq .nextBuildNumber) + FIRST_BUILD=$(curl -s "$API_ROUTE/api/json" | jq .nextBuildNumber) LAST_BUILD=$((FIRST_BUILD+N-1)) - TEST_BUILDS=( $(seq $FIRST_BUILD $LAST_BUILD) ) + read -r -a TEST_BUILDS <<< "$(seq -s ' ' "$FIRST_BUILD" "$LAST_BUILD")" # Start N new builds - for i in ${TEST_BUILDS[@]}; + for i in "${TEST_BUILDS[@]}"; do echo "Starting build $i" - curl -s --output /dev/null --cookie $COOKIE_JAR -H "$CRUMB" -X POST $API_ROUTE/build?delay=0sec + curl -s --output /dev/null --cookie "$COOKIE_JAR" -H "$CRUMB" -X POST "$API_ROUTE/build?delay=0sec" sleep 5 done echo "" @@ -58,14 +58,14 @@ function loop() { sleep 30 count=0 - for i in ${TEST_BUILDS[@]}; + for i in "${TEST_BUILDS[@]}"; do - RES=$(curl -s -w "\n%{http_code}" --cookie $COOKIE_JAR -H "$CRUMB" $API_ROUTE/$i/api/json) + RES=$(curl -s -w "\n%{http_code}" --cookie "$COOKIE_JAR" -H "$CRUMB" "$API_ROUTE/$i/api/json") HTTP_CODE=$(tail -n1 <<< "$RES") JSON=$(sed '$ d' <<< "$RES") if [[ $HTTP_CODE == "200" ]]; then - STILL_RUNNING=$(echo $JSON | jq .inProgress) + STILL_RUNNING=$(echo "$JSON" | jq .inProgress) if [[ $STILL_RUNNING == "true" ]]; then echo -e "Build $i: ${YELLOW}still running${NC}" continue @@ -119,11 +119,11 @@ function _looper() { echo -e "You are about to start $RUNS Jenkins builds against the $BRANCH branch." echo -e "If you expect this to run overnight, ${UNDERLINE}${BOLD}unplug the cold reboot power switch${NC} from the testing closet before." echo "" - read -p "Press (y/Y) to confirm: " choice + read -r -p "Press (y/Y) to confirm: " choice if [[ "$choice" == "y" || "$choice" == "Y" ]]; then loop fi } -_looper $@ +_looper "$@" diff --git a/scripts/launch_corolla.sh b/scripts/launch_corolla.sh index 926569a1e0..8894caaf74 100755 --- a/scripts/launch_corolla.sh +++ b/scripts/launch_corolla.sh @@ -4,4 +4,4 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" export FINGERPRINT="TOYOTA_COROLLA_TSS2" export SKIP_FW_QUERY="1" -$DIR/../launch_openpilot.sh +"$DIR/../launch_openpilot.sh" diff --git a/scripts/lint/check_nomerge_comments.sh b/scripts/lint/check_nomerge_comments.sh index 6737d62a20..0dc3a7bd73 100755 --- a/scripts/lint/check_nomerge_comments.sh +++ b/scripts/lint/check_nomerge_comments.sh @@ -2,9 +2,9 @@ FAIL=0 -if grep -n '\(#\|//\)\([[:space:]]*\)NOMERGE' $@; then +if grep -n '\(#\|//\)\([[:space:]]*\)NOMERGE' "$@"; then echo -e "NOMERGE comments found! Remove them before merging\n" FAIL=1 fi -exit $FAIL +exit "$FAIL" diff --git a/scripts/lint/check_shebang_format.sh b/scripts/lint/check_shebang_format.sh index 89b95d5929..6a3657a10b 100755 --- a/scripts/lint/check_shebang_format.sh +++ b/scripts/lint/check_shebang_format.sh @@ -2,14 +2,14 @@ FAIL=0 -if grep '^#!.*python' $@ | grep -v '#!/usr/bin/env python3$'; then +if grep '^#!.*python' "$@" | grep -v '#!/usr/bin/env python3$'; then echo -e "Invalid shebang! Must use '#!/usr/bin/env python3'\n" FAIL=1 fi -if grep '^#!.*bash' $@ | grep -v '#!/usr/bin/env bash$'; then +if grep '^#!.*bash' "$@" | grep -v '#!/usr/bin/env bash$'; then echo -e "Invalid shebang! Must use '#!/usr/bin/env bash'" FAIL=1 fi -exit $FAIL +exit "$FAIL" diff --git a/scripts/lint/check_shell.py b/scripts/lint/check_shell.py new file mode 100755 index 0000000000..d34bd6e454 --- /dev/null +++ b/scripts/lint/check_shell.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""A minimal shellcheck-like static analysis tool for shell scripts. + +Covers syntax, unquoted expansions, scalar $@ assignments, and read -r. +""" +import re +import argparse +import subprocess +from pathlib import Path + +VARIABLE = re.compile(r"\$(?:\{[^}\n]*\}|[A-Za-z_]\w*|[@*0-9])") +ASSIGNMENT = re.compile(r"[A-Za-z_]\w*(?:\[[^]]*\])?\+?=") +OPAQUE = re.compile(r"\$?\(\([^\n]*?\)\)|\[\[.*?\]\]", re.DOTALL) +HEREDOC = re.compile(r"<<(-?)\s*('[^']+'|\"[^\"]+\"|\\?[A-Za-z_]\w*)") + + +def commands(text): + def scan(i=0, end="", pattern_group=False): + start, quote, words, expansions, documents = i, False, [], [], [] + cases = [] + while i < len(text): + c = text[i] + if c == "\\": + i += 2 + continue + if c == "'" and not quote: + i = text.find("'", i + 1) + 1 or len(text) + continue + if c == '"': + quote = not quote + elif (not quote or text.startswith("$((", i)) and (match := OPAQUE.match(text, i)): + i = match.end() + continue + elif text.startswith("$(", i): + if not quote: + expansions.append(i) + i = yield from scan(i + 2, ")") + continue + elif match := VARIABLE.match(text, i): + if not quote and not match[0].startswith("${#"): + expansions.append(i) + i = match.end() + continue + elif not quote: + if text.startswith("<<<", i): + i += 3 + continue + if c == "#" and i == start: + i = text.find("\n", i) + i = len(text) if i < 0 else i + start = i + continue + if not text.startswith("<<<", i) and (match := HEREDOC.match(text, i)): + documents.append((match[2].strip("'\"").lstrip("\\"), bool(match[1]))) + i = match.end() + continue + if c in " \t\r\n;|&()": + if start < i: + words.append((text[start:i], start, expansions)) + if len(words) >= 3 and words[0][0] == "case" and words[-1][0] == "in": + cases.append(True) + words = [] + if words and words[0][0] == "esac" and cases: + cases.pop() + words = [] + pattern = bool(cases and cases[-1]) + expansions = [] + if c in "\n;|&()": + if words and not (pattern or pattern_group) and (c != ")" or end): + yield words + words = [] + if c == end and not pattern: + return i + 1 + if c == ")" and pattern: + cases[-1] = False + if cases and (terminator := re.match(r";(?:;&|;|&)", text[i:])): + cases[-1] = True + i += len(terminator[0]) - 1 + if c == "(": + if pattern and i == start: + cases[-1] = False + i = yield from scan(i + 1, ")", pattern or pattern_group) + start = i + continue + if c == "\n": + for delimiter, strip_tabs in documents: + while i < len(text): + stop = text.find("\n", i + 1) + stop = len(text) if stop < 0 else stop + line, i = text[i + 1:stop], stop + if (line.lstrip("\t") if strip_tabs else line) == delimiter: + break + documents = [] + start = i + 1 + i += 1 + if start < i: + words.append((text[start:i], start, expansions)) + if words: + yield words + return i + yield from scan() + + +def check_text(text): + for words in commands(text): + command = next((w for w, _, _ in words if not ASSIGNMENT.match(w) and w not in {"if", "then", "elif", "while", "until", "do", "!"}), "") + prefix = True + for index, (word, start, expansions) in enumerate(words): + assignment = ASSIGNMENT.match(word) and (prefix or command in {"export", "local", "declare", "readonly", "typeset"}) + prefix = prefix and (bool(assignment) or word in {"if", "then", "elif", "while", "until", "do", "!"}) + for offset in expansions: + array = word in {"$@", "$*"} or "[@]" in word or "[*]" in word + if not assignment and (command not in {"case", "for", "select"} or array) and not (index and words[index - 1][0] == "<<<"): + yield text.count("\n", 0, offset) + 1, "Quote this expansion to prevent word splitting and globbing" + if assignment and re.fullmatch(r'"[^"\n]*\$@[^"\n]*"', ASSIGNMENT.sub("", word, count=1)): + yield text.count("\n", 0, start) + 1, 'Use an array for "$@", or "$*" to join arguments' + if command == "read" and not any(re.fullmatch(r"-[A-Za-z]*r[A-Za-z0-9]*", w) for w, _, _ in words): + yield text.count("\n", 0, next(start for w, start, _ in words if w == "read")) + 1, "Use read -r to preserve backslashes" + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("filenames", nargs="+") + failed = False + for filename in parser.parse_args().filenames: + syntax = subprocess.run(["bash", "-n", "--", filename], check=False) + failed |= syntax.returncode != 0 + if syntax.returncode == 0: + for line, message in check_text(Path(filename).read_text()): + print(f"{filename}:{line}: {message}") + failed = True + raise SystemExit(failed) diff --git a/scripts/lint/lint.sh b/scripts/lint/lint.sh index fe3644d670..b05b2475cb 100755 --- a/scripts/lint/lint.sh +++ b/scripts/lint/lint.sh @@ -9,7 +9,7 @@ NC='\033[0m' DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" ROOT="$DIR/../../" -cd $ROOT +cd "$ROOT" FAILED=0 @@ -26,10 +26,10 @@ function run() { done shift 1; - CMD="$@" + CMD=("$@") set +e - log="$((eval "$CMD" ) 2>&1)" + log="$("${CMD[@]}" 2>&1)" if [[ $? -eq 0 ]]; then echo -e "[${GREEN}✔${NC}]" @@ -42,23 +42,21 @@ function run() { } function run_tests() { - ALL_FILES=$1 - PYTHON_FILES=$2 - run "ruff" ruff check openpilot --quiet - run "check_dependencies" python3 $DIR/check_dependencies.py - run "check_indentation" $DIR/check_indentation.py $PYTHON_FILES - run "check_added_large_files" $DIR/check_added_large_files.py --maxkb=120 $ALL_FILES - run "check_shebang_scripts_are_executable" $DIR/check_shebang_scripts_are_executable.py $ALL_FILES - run "check_shebang_format" $DIR/check_shebang_format.sh $ALL_FILES - run "check_nomerge_comments" $DIR/check_nomerge_comments.sh $ALL_FILES + run "check_shell" python3 "$DIR/check_shell.py" "${SHELL_FILES[@]}" + run "check_dependencies" python3 "$DIR/check_dependencies.py" + run "check_indentation" "$DIR/check_indentation.py" "${PYTHON_FILES[@]}" + run "check_added_large_files" "$DIR/check_added_large_files.py" --maxkb=120 "${ALL_FILES[@]}" + run "check_shebang_scripts_are_executable" "$DIR/check_shebang_scripts_are_executable.py" "${ALL_FILES[@]}" + run "check_shebang_format" "$DIR/check_shebang_format.sh" "${ALL_FILES[@]}" + run "check_nomerge_comments" "$DIR/check_nomerge_comments.sh" "${ALL_FILES[@]}" if [[ -z "$FAST" ]]; then run "ty" ty check openpilot - run "codespell" codespell $ALL_FILES + run "codespell" codespell "${ALL_FILES[@]}" fi - return $FAILED + return "$FAILED" } function help() { @@ -68,6 +66,7 @@ function help() { echo "" echo -e "${BOLD}${UNDERLINE}Tests:${NC}" echo -e " ${BOLD}ruff${NC}" + echo -e " ${BOLD}check_shell${NC}" echo -e " ${BOLD}check_dependencies${NC}" echo -e " ${BOLD}check_indentation${NC}" echo -e " ${BOLD}ty${NC}" @@ -103,16 +102,26 @@ while [[ $# -gt 0 ]]; do esac done -RUN=$([ -z "$RUN" ] && echo "" || echo "!($(echo $RUN | sed 's/ /|/g'))") -SKIP="@($(echo $SKIP | sed 's/ /|/g'))" +RUN=$([ -z "$RUN" ] && echo "" || echo "!($(echo "$RUN" | sed 's/ /|/g'))") +SKIP="@($(echo "$SKIP" | sed 's/ /|/g'))" -GIT_FILES="$(git ls-files openpilot)" -ALL_FILES="" -for f in $GIT_FILES; do +ALL_FILES=() +PYTHON_FILES=() +while IFS= read -r -d '' f; do if [[ -f $f ]]; then - ALL_FILES+="$f"$'\n' + ALL_FILES+=("$f") + if [[ $f == *.py ]]; then + PYTHON_FILES+=("$f") + fi fi -done -PYTHON_FILES=$(echo "$ALL_FILES" | grep --color=never '.py$' || true) +done < <(git ls-files -z openpilot) -run_tests "$ALL_FILES" "$PYTHON_FILES" +# Include tooling, launchers, and the extensionless Git hook. +SHELL_FILES=() +while IFS= read -r -d '' f; do + if [[ -f $f ]]; then + SHELL_FILES+=("$f") + fi +done < <(git ls-files -z '*.sh' '*.bash' scripts/post-commit) + +run_tests diff --git a/scripts/retry.sh b/scripts/retry.sh index 23501d7559..382190fe10 100755 --- a/scripts/retry.sh +++ b/scripts/retry.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash function fail { - echo $1 >&2 + echo "$1" >&2 exit 1 } @@ -14,7 +14,7 @@ function retry { "$@" && break || { if [[ $n -lt $max ]]; then ((n++)) - sleep $delay; + sleep "$delay"; else fail "The command has failed after $n attempts." fi diff --git a/tools/op.sh b/tools/op.sh index 183b4b1012..d1a7dcebbb 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -14,9 +14,9 @@ UNDERLINE='\033[4m' BOLD='\033[1m' NC='\033[0m' -SHELL_NAME="$(basename ${SHELL})" -RC_FILE="${HOME}/.$(basename ${SHELL})rc" -if [ "$(uname)" == "Darwin" ] && [ $SHELL == "/bin/bash" ]; then +SHELL_NAME="$(basename "${SHELL}")" +RC_FILE="${HOME}/.$(basename "${SHELL}")rc" +if [ "$(uname)" == "Darwin" ] && [ "$SHELL" == "/bin/bash" ]; then RC_FILE="$HOME/.bash_profile" fi @@ -250,7 +250,7 @@ function op_venv() { case $SHELL_NAME in "zsh") ZSHRC_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'tmp_zsh') - echo "source \"$RC_FILE\"; source \"$OPENPILOT_ROOT/.venv/bin/activate\"" >> $ZSHRC_DIR/.zshrc + echo "source \"$RC_FILE\"; source \"$OPENPILOT_ROOT/.venv/bin/activate\"" >> "$ZSHRC_DIR/.zshrc" ZDOTDIR=$ZSHRC_DIR zsh ;; *) bash --rcfile <(echo "source \"$RC_FILE\"; source \"$OPENPILOT_ROOT/.venv/bin/activate\"") ;; @@ -405,14 +405,14 @@ function op_start() { if [[ -f "/AGNOS" ]]; then op_before_cmd op_check_agnos_update - op_run_command sudo systemctl restart comma $@ + op_run_command sudo systemctl restart comma "$@" fi } function op_stop() { if [[ -f "/AGNOS" ]]; then op_before_cmd - op_run_command sudo systemctl stop comma $@ + op_run_command sudo systemctl stop comma "$@" fi } diff --git a/tools/release/build_release.sh b/tools/release/build_release.sh index cced9f7a54..6dcf99957f 100755 --- a/tools/release/build_release.sh +++ b/tools/release/build_release.sh @@ -3,7 +3,7 @@ set -e set -x DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -cd $DIR +cd "$DIR" BUILD_DIR=/data/openpilot SOURCE_DIR="$(git rev-parse --show-toplevel)" @@ -19,26 +19,26 @@ BUILD_BRANCH=release-mici-staging # set git identity -source $DIR/identity.sh +source "$DIR/identity.sh" echo "[-] Setting up repo T=$SECONDS" if ! git -C "$SOURCE_DIR" worktree remove --force "$BUILD_DIR" 2>/dev/null; then - rm -rf $BUILD_DIR + rm -rf "$BUILD_DIR" fi git -C "$SOURCE_DIR" worktree prune git -C "$SOURCE_DIR" worktree add --detach --no-checkout "$BUILD_DIR" -cd $BUILD_DIR +cd "$BUILD_DIR" git update-ref -d "refs/heads/$BUILD_BRANCH" git symbolic-ref HEAD "refs/heads/$BUILD_BRANCH" git read-tree --empty # do the files copy echo "[-] copying files T=$SECONDS" -cd $SOURCE_DIR +cd "$SOURCE_DIR" ./tools/release/release_files.py | xargs -0 cp -pR --parents -t "$BUILD_DIR" -- # in the directory -cd $BUILD_DIR +cd "$BUILD_DIR" # use the full CPU available for speeding up the build. # openpilot resets the CPU frequencies when test_onroad.py runs below. @@ -88,7 +88,7 @@ git -c core.compression=0 add -f . git -c core.compression=0 -c gc.auto=0 commit -m "openpilot v$VERSION" # Run tests -cd $BUILD_DIR +cd "$BUILD_DIR" RELEASE=1 ./openpilot/selfdrive/test/test_onroad.py "$@" #tools/test_runner.py openpilot/selfdrive/car/tests/test_car_interfaces.py diff --git a/tools/release/build_stripped.sh b/tools/release/build_stripped.sh index a216dfe11c..239add2518 100755 --- a/tools/release/build_stripped.sh +++ b/tools/release/build_stripped.sh @@ -3,23 +3,23 @@ set -ex DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -SOURCE_DIR="$(git -C $DIR rev-parse --show-toplevel)" +SOURCE_DIR="$(git -C "$DIR" rev-parse --show-toplevel)" if [ -z "$TARGET_DIR" ]; then TARGET_DIR="$(mktemp -d)" fi # set git identity -source $DIR/identity.sh +source "$DIR/identity.sh" echo "[-] Setting up target repo T=$SECONDS" -rm -rf $TARGET_DIR -mkdir -p $TARGET_DIR -cd $TARGET_DIR -cp -r $SOURCE_DIR/.git $TARGET_DIR +rm -rf "$TARGET_DIR" +mkdir -p "$TARGET_DIR" +cd "$TARGET_DIR" +cp -r "$SOURCE_DIR/.git" "$TARGET_DIR" echo "[-] setting up stripped branch sync T=$SECONDS" -cd $TARGET_DIR +cd "$TARGET_DIR" # tmp branch git checkout --orphan tmp @@ -32,20 +32,20 @@ find . -maxdepth 1 -not -path './.git' -not -name '.' -not -name '..' -exec rm - # do the files copy echo "[-] copying files T=$SECONDS" -cd $SOURCE_DIR +cd "$SOURCE_DIR" ./tools/release/release_files.py | xargs -0 cp -pR --parents -t "$TARGET_DIR" -- # in the directory -cd $TARGET_DIR +cd "$TARGET_DIR" rm -rf .git/modules/ find openpilot/selfdrive/modeld/models -name '*.onnx' -size +95M -exec ./openpilot/common/file_chunker.py {} \; # include source commit hash and build date in commit -GIT_HASH=$(git --git-dir=$SOURCE_DIR/.git rev-parse HEAD) -GIT_COMMIT_DATE=$(git --git-dir=$SOURCE_DIR/.git show --no-patch --format='%ct %ci' HEAD) +GIT_HASH=$(git --git-dir="$SOURCE_DIR/.git" rev-parse HEAD) +GIT_COMMIT_DATE=$(git --git-dir="$SOURCE_DIR/.git" show --no-patch --format='%ct %ci' HEAD) DATETIME=$(date '+%Y-%m-%dT%H:%M:%S') -VERSION=$(cat $SOURCE_DIR/openpilot/common/version.h | awk -F\" '{print $2}') +VERSION=$(cat "$SOURCE_DIR/openpilot/common/version.h" | awk -F\" '{print $2}') echo -n "$GIT_HASH" > git_src_commit echo -n "$GIT_COMMIT_DATE" > git_src_commit_date @@ -82,7 +82,7 @@ if [ ! -z "$BRANCH" ]; then git config --local core.hooksPath .git/hooks git lfs update --force # uploading the larger pack is faster than spending CPU to optimize it - git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin tmp:$BRANCH + git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin "tmp:$BRANCH" fi echo "[-] done T=$SECONDS, ready at $TARGET_DIR" diff --git a/tools/release/check-dirty.sh b/tools/release/check-dirty.sh index ac049970cf..73d393f3c2 100755 --- a/tools/release/check-dirty.sh +++ b/tools/release/check-dirty.sh @@ -2,7 +2,7 @@ set -e DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -cd $DIR +cd "$DIR" if [ ! -z "$(git status --porcelain)" ]; then echo "Dirty working tree after build:" diff --git a/tools/release/check-submodules.sh b/tools/release/check-submodules.sh index 93869a7403..c93bb58752 100755 --- a/tools/release/check-submodules.sh +++ b/tools/release/check-submodules.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash -while read hash submodule ref; do +while read -r hash submodule ref; do if [ "$submodule" = "tinygrad_repo" ]; then echo "Skipping $submodule" continue fi - git -C $submodule fetch --depth 100 origin master - git -C $submodule branch -r --contains $hash | grep "origin/master" + git -C "$submodule" fetch --depth 100 origin master + git -C "$submodule" branch -r --contains "$hash" | grep "origin/master" if [ "$?" -eq 0 ]; then echo "$submodule ok" else diff --git a/tools/scripts/adb_ssh.sh b/tools/scripts/adb_ssh.sh index c584a72f2b..b808243d36 100755 --- a/tools/scripts/adb_ssh.sh +++ b/tools/scripts/adb_ssh.sh @@ -36,7 +36,7 @@ SSH_PORT=2222 while ss -tln | grep -q ":${SSH_PORT} "; do SSH_PORT=$((SSH_PORT + 1)) done -adb forward tcp:${SSH_PORT} tcp:22 +adb forward tcp:"${SSH_PORT}" tcp:22 # SSH! -ssh comma@localhost -p ${SSH_PORT} "$@" +ssh comma@localhost -p "${SSH_PORT}" "$@" diff --git a/tools/setup.sh b/tools/setup.sh index ced451ab11..81eb543114 100755 --- a/tools/setup.sh +++ b/tools/setup.sh @@ -59,10 +59,10 @@ function ask_dir() { return 0 fi - read + read -r if [[ ! -z "$REPLY" ]]; then - mkdir -p $REPLY - OPENPILOT_ROOT="$(realpath $REPLY)/openpilot" + mkdir -p "$REPLY" + OPENPILOT_ROOT="$(realpath "$REPLY")/openpilot" fi } @@ -114,7 +114,7 @@ function check_git() { function git_clone() { st="$(date +%s)" echo "Cloning openpilot..." - if $(git clone --filter=blob:none https://github.com/commaai/openpilot.git "$OPENPILOT_ROOT"); then + if git clone --filter=blob:none https://github.com/commaai/openpilot.git "$OPENPILOT_ROOT"; then if [[ -f $OPENPILOT_ROOT/launch_openpilot.sh ]]; then et="$(date +%s)" echo -e " ↳ [${GREEN}✔${NC}] Successfully cloned openpilot in $((et - st)) seconds.\n" @@ -127,10 +127,10 @@ function git_clone() { } function install_with_op() { - cd $OPENPILOT_ROOT - $OPENPILOT_ROOT/tools/op.sh post-commit + cd "$OPENPILOT_ROOT" + "$OPENPILOT_ROOT/tools/op.sh" post-commit - if ! $OPENPILOT_ROOT/tools/op.sh setup; then + if ! "$OPENPILOT_ROOT/tools/op.sh" setup; then echo -e "\n[${RED}✗${NC}] failed to install openpilot!" return 1 fi @@ -147,5 +147,5 @@ check_stdin ask_dir check_dir check_git -[ -z $SKIP_GIT_CLONE ] && git_clone +[ -z "$SKIP_GIT_CLONE" ] && git_clone install_with_op diff --git a/tools/setup_dependencies.sh b/tools/setup_dependencies.sh index 7af2180686..c9e635836b 100755 --- a/tools/setup_dependencies.sh +++ b/tools/setup_dependencies.sh @@ -20,14 +20,14 @@ function retry() { } function install_linux_deps() { - SUDO="" + SUDO=() if [[ ! $(id -u) -eq 0 ]]; then if [[ -z $(which sudo) ]]; then echo "Please install sudo or run as root" exit 1 fi - SUDO="sudo" + SUDO=(sudo) fi local missing_linux_deps=0 @@ -51,28 +51,28 @@ function install_linux_deps() { # the native package managers are slow, so skip if we can echo "[ ] system packages already installed t=$SECONDS" elif command -v apt-get > /dev/null 2>&1; then - $SUDO apt-get update - $SUDO apt-get install -y --no-install-recommends ca-certificates build-essential curl libcurl4-openssl-dev locales git xclip wl-clipboard + "${SUDO[@]}" apt-get update + "${SUDO[@]}" apt-get install -y --no-install-recommends ca-certificates build-essential curl libcurl4-openssl-dev locales git xclip wl-clipboard elif command -v dnf > /dev/null 2>&1; then - $SUDO dnf install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git + "${SUDO[@]}" dnf install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git elif command -v yum > /dev/null 2>&1; then - $SUDO yum install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git + "${SUDO[@]}" yum install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git elif command -v pacman > /dev/null 2>&1; then - $SUDO pacman -Syu --noconfirm --needed base-devel ca-certificates curl git + "${SUDO[@]}" pacman -Syu --noconfirm --needed base-devel ca-certificates curl git elif command -v zypper > /dev/null 2>&1; then - $SUDO zypper --non-interactive refresh - $SUDO zypper --non-interactive install ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-locale git + "${SUDO[@]}" zypper --non-interactive refresh + "${SUDO[@]}" zypper --non-interactive install ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-locale git elif command -v apk > /dev/null 2>&1; then - $SUDO apk add --no-cache ca-certificates build-base curl curl-dev musl-locales git + "${SUDO[@]}" apk add --no-cache ca-certificates build-base curl curl-dev musl-locales git elif command -v xbps-install > /dev/null 2>&1; then - $SUDO xbps-install -Syu base-devel ca-certificates curl git libcurl-devel glibc-locales + "${SUDO[@]}" xbps-install -Syu base-devel ca-certificates curl git libcurl-devel glibc-locales else echo "Unsupported Linux distribution. Supported package managers: apt-get, dnf, yum, pacman, zypper, apk, xbps-install." exit 1 fi if [[ -d "/etc/udev/rules.d/" ]]; then - $SUDO tee /etc/udev/rules.d/11-openpilot.rules > /dev/null <<-EOF + "${SUDO[@]}" tee /etc/udev/rules.d/11-openpilot.rules > /dev/null <<-EOF # Panda Jungle devices SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddcf", MODE="0666" SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddef", MODE="0666" @@ -91,9 +91,9 @@ function install_linux_deps() { EOF # delete the old ones - $SUDO rm -f /etc/udev/rules.d/11-panda.rules /etc/udev/rules.d/12-panda_jungle.rules /etc/udev/rules.d/50-comma-adb.rules + "${SUDO[@]}" rm -f /etc/udev/rules.d/11-panda.rules /etc/udev/rules.d/12-panda_jungle.rules /etc/udev/rules.d/50-comma-adb.rules - $SUDO udevadm control --reload-rules && $SUDO udevadm trigger || true + "${SUDO[@]}" udevadm control --reload-rules && "${SUDO[@]}" udevadm trigger || true fi } From 19f0f69d85969eaf2cedcb884d69e83c8fd21840 Mon Sep 17 00:00:00 2001 From: commaci-public <60409688+commaci-public@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:28:19 -0700 Subject: [PATCH 078/270] [bot] Update Python packages (#38698) * Update Python packages * Fix pandad exports --------- Co-authored-by: Vehicle Researcher Co-authored-by: Adeeb Shihadeh --- docs/CARS.md | 14 +- opendbc_repo | 2 +- openpilot/selfdrive/pandad/__init__.py | 4 +- panda | 2 +- rednose_repo | 2 +- tinygrad_repo | 2 +- uv.lock | 504 ++++++++++++------------- 7 files changed, 264 insertions(+), 266 deletions(-) diff --git a/docs/CARS.md b/docs/CARS.md index 1e0bd07b77..5cf1932caa 100644 --- a/docs/CARS.md +++ b/docs/CARS.md @@ -34,7 +34,7 @@ A supported vehicle is one that just works when you install a comma device. All |Chrysler|Pacifica Hybrid 2019-25|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|

Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |comma|body|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|None||| |CUPRA[12](#footnotes)|Ateca 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|CUPRA[12](#footnotes)|Born 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|CUPRA|Born 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Dodge|Durango 2020-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Bronco Sport 2021-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Escape 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -99,7 +99,7 @@ A supported vehicle is one that just works when you install a comma device. All |Honda|Fit 2018-20|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Freed 2020|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|HR-V 2019-22|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Honda|HR-V 2023-25|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|HR-V 2023-27|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Insight 2019-22|All|openpilot available[1,5](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Inspire 2018|All|openpilot available[1,5](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|N-Box 2018|All|openpilot available[1,5](#footnotes)|0 mph|11 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -258,8 +258,8 @@ A supported vehicle is one that just works when you install a comma device. All |Škoda[12](#footnotes)|Superb 2015-22[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Tesla[10](#footnotes)|Model 3 (with HW3) 2019-23[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Tesla[10](#footnotes)|Model 3 (with HW4) 2024-25[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Tesla[10](#footnotes)|Model Y (with HW3) 2020-23[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Tesla[10](#footnotes)|Model Y (with HW4) 2024-25[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Tesla[10](#footnotes)|Model Y (with HW3) 2020-24[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Tesla[10](#footnotes)|Model Y (with HW4) 2023-25[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Toyota|Alphard 2019-20|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Alphard Hybrid 2021|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Avalon 2016|Toyota Safety Sense P|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -325,8 +325,8 @@ A supported vehicle is one that just works when you install a comma device. All |Volkswagen[12](#footnotes)|Golf R 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Golf SportsVan 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Grand California 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|ID.4 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|ID.4 2024-25|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|ID.4 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|ID.4 2024-25|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Jetta 2019-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Jetta GLI 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen|Passat 2015-22[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| @@ -353,7 +353,7 @@ A supported vehicle is one that just works when you install a comma device. All 6See more setup details for Nissan.
7In the non-US market, openpilot requires the car to come equipped with EyeSight with Lane Keep Assistance.
8Enabling longitudinal control (alpha) will disable all EyeSight functionality, including AEB, LDW, and RAB.
-9Some 2023 model years have HW4. To check which hardware type your vehicle has, look for Autopilot computer under Software -> Additional Vehicle Information on your vehicle's touchscreen. See this page for more information.
+9Model years 2023 and 2024 can have either hardware type, depending on build date and factory. To check which hardware type your vehicle has, look for Autopilot computer under Software -> Additional Vehicle Information on your vehicle's touchscreen. See this page for more information.
10See more setup details for Tesla.
11openpilot operates above 28mph for Camry 4CYL L, 4CYL LE and 4CYL SE which don't have Full-Speed Range Dynamic Radar Cruise Control.
12The J533 harness plugs in at the CAN gateway under the dashboard, just above the steering column. More information can be found at this guide.
diff --git a/opendbc_repo b/opendbc_repo index b4ef5e1cf4..a3d3b7c6ca 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit b4ef5e1cf406ff143fa67bdbfb154739d43279c9 +Subproject commit a3d3b7c6ca76ed4c97f606e9b2a0e371e8fa52af diff --git a/openpilot/selfdrive/pandad/__init__.py b/openpilot/selfdrive/pandad/__init__.py index 0c17e886a2..5f7a8fec86 100644 --- a/openpilot/selfdrive/pandad/__init__.py +++ b/openpilot/selfdrive/pandad/__init__.py @@ -1,3 +1 @@ -from openpilot.selfdrive.pandad.pandad_api_impl import can_list_to_can_capnp, can_capnp_to_list -assert can_list_to_can_capnp -assert can_capnp_to_list +from openpilot.selfdrive.pandad.pandad_api_impl import can_list_to_can_capnp as can_list_to_can_capnp, can_capnp_to_list as can_capnp_to_list diff --git a/panda b/panda index 75aa44bec9..5314c84d9e 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit 75aa44bec9140849868239b1f1e3f22624adb8fe +Subproject commit 5314c84d9e8d18aada38bd4fff86b2d42b6eb502 diff --git a/rednose_repo b/rednose_repo index 28d4a7f69e..8671c17c3a 160000 --- a/rednose_repo +++ b/rednose_repo @@ -1 +1 @@ -Subproject commit 28d4a7f69e80e1c3e0d24ca0733d7daeaeade3d0 +Subproject commit 8671c17c3a4cdc4be5df07a068039e2da5b94eaa diff --git a/tinygrad_repo b/tinygrad_repo index f6fc4e3f2c..76059714bf 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit f6fc4e3f2c3db5fae1e19cbfbc3ad9fc579a12ae +Subproject commit 76059714bf4468363b52358434f7ce35be7f425f diff --git a/uv.lock b/uv.lock index fe85a470bc..9b1ffef34c 100644 --- a/uv.lock +++ b/uv.lock @@ -98,229 +98,229 @@ wheels = [ [[package]] name = "comma-deps-acados" -version = "0.2.2.post98" +version = "0.2.2.post103" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/13/1190aed06e91a9f9024b16fb44a4184842e56ac39dbaec8e6aea83cb1d7e/comma_deps_acados-0.2.2.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64c002e0d6170c7bdec300159bbbe07cf3e05fa54ae5890fe736190fc3543fe7", size = 10635996, upload-time = "2026-07-23T17:01:04.136Z" }, - { url = "https://files.pythonhosted.org/packages/d6/24/a16888f692e2a2759b656847b7e6ca2dbbca666e64e1372e3e158d25e29f/comma_deps_acados-0.2.2.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:356ce6020430eb5b53d637bf870c27b10ad8599c9ade0abd2c520c3e00143410", size = 11657744, upload-time = "2026-07-23T17:01:08.618Z" }, - { url = "https://files.pythonhosted.org/packages/73/9d/24377b731093e015a44fff043dd7ea5b77b0de62acf48b5a0e7d5a662a15/comma_deps_acados-0.2.2.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:e55ac429d848415930a0b82ab100a310e1848b48cdbaa8dda574b561b43c50d0", size = 13124767, upload-time = "2026-07-23T17:01:13.091Z" }, + { url = "https://files.pythonhosted.org/packages/12/bd/b8bbb3791b42c5aef32c5ebaeaebcf4d1d712cc9e67222523daa179c7419/comma_deps_acados-0.2.2.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1067e2404c08bab1ebcdd46b00d505f956d2edacc9f50d307b0618b35eff0421", size = 10554192, upload-time = "2026-09-11T22:31:14.412Z" }, + { url = "https://files.pythonhosted.org/packages/a3/72/31488259bb173161089393e7383ae90a7f3fb76b5d41098639a78bd9699f/comma_deps_acados-0.2.2.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2ad9fcebef1f65112a9cebe8a093977310602e1325d442dc345beaf512679211", size = 11670466, upload-time = "2026-09-11T22:31:18.634Z" }, + { url = "https://files.pythonhosted.org/packages/98/eb/83c5ab1275b812ac10988e1ddbfd541088e8a5a883e45f34bd0ee35e2138/comma_deps_acados-0.2.2.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3b451852e83d62815cead999ab31073db9be60307f772650336cdd4534f12b9e", size = 13139433, upload-time = "2026-09-11T22:31:22.891Z" }, ] [[package]] name = "comma-deps-bootstrap-icons" -version = "1.10.5.0.post98" +version = "1.10.5.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/69/da1a72b8b7783b0caf9a54b27c7124bad11768b8bce2c656ef3b700ab831/comma_deps_bootstrap_icons-1.10.5.0.post98-py3-none-any.whl", hash = "sha256:cabaeecea398eb867b96a6c653c6078691a437c0eff2364530194a218d94cb99", size = 385998, upload-time = "2026-07-23T17:01:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/21/17/277a473fb1f6b28f88ca0b6c477b1ddf439f68371c0d4e80fdc7925f98ea/comma_deps_bootstrap_icons-1.10.5.0.post103-py3-none-any.whl", hash = "sha256:9879fa2b3b112d20f4acf6cc83bb9732f95f0864db74d25224a31fa9827f37ae", size = 386008, upload-time = "2026-09-11T22:31:27.373Z" }, ] [[package]] name = "comma-deps-capnproto" -version = "1.0.1.post98" +version = "1.0.1.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/83/d3e6346a31491be1d378e4585f37a7979eb772018616abfa74fb27750f1e/comma_deps_capnproto-1.0.1.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9f4d08682df92411b360bec855cb6475990313cd0ecd8ed5c6ee02befb9db913", size = 2407343, upload-time = "2026-07-23T17:01:21.247Z" }, - { url = "https://files.pythonhosted.org/packages/b0/8b/6f2a29d50ed4c8741dbf0a34ab109899268d09753518cd693e881bbf1a9d/comma_deps_capnproto-1.0.1.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6cdf838a8d415ac71e1f52306624ab3ab6f27777f6ce89c0059c4129ea7b7f62", size = 2506355, upload-time = "2026-07-23T17:01:25.254Z" }, - { url = "https://files.pythonhosted.org/packages/08/24/e91f2203d62e4db9de7dae06dd0cdefb1e000d8b3ba0bde48367be7e5b63/comma_deps_capnproto-1.0.1.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3d95993c9aff0c89e39ca965e995021dd3dccdfc3d5d85152916cf4bf651b7ec", size = 2590764, upload-time = "2026-07-23T17:01:29.062Z" }, + { url = "https://files.pythonhosted.org/packages/61/ef/a108b56be5bd6a655c4acaf9c7d6a11c4397d5440bd192314dc2b4e5b997/comma_deps_capnproto-1.0.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cfea5b5679a530affc674717173455e1bb26bde06491283f461d8c070df33811", size = 2404560, upload-time = "2026-09-11T22:31:31.176Z" }, + { url = "https://files.pythonhosted.org/packages/c4/53/38b0e046aab3f0d371d0fa6eb3d4eb3cf787e7d270f429a485e53080416c/comma_deps_capnproto-1.0.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4a115a789af5835dde75371abdd0c1766df40764eaccfd912de4212522935d08", size = 2503570, upload-time = "2026-09-11T22:31:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/22/7c/fcbdd3ac0f635a3b1eb674e5bd4c3ada804c89348c1a3818ca2fc91e3d91/comma_deps_capnproto-1.0.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f5717a6e6bee615bca9a68e9a5d10a11a871e0f61ee2d66759681de562d82c9a", size = 2587991, upload-time = "2026-09-11T22:31:38.822Z" }, ] [[package]] name = "comma-deps-eigen" -version = "3.4.0.post98" +version = "3.4.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/2f/89011c71976da6e1c3d7be315afa3d86ff25deeada1ad2319ac6be0e18ea/comma_deps_eigen-3.4.0.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd182634cf4fa537e7815238d3135e6e89be5826421a495be92258c6c388b527", size = 2275893, upload-time = "2026-07-23T17:01:44.622Z" }, - { url = "https://files.pythonhosted.org/packages/2c/61/fcd4ad536c51437ee73ac255f3b8a23fb5f21bb1f96e834d8036c3bbcf08/comma_deps_eigen-3.4.0.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:250c15f6217c37736a2f54298d01f6e32f6e977faa87cc358de67ea3d121725e", size = 2275896, upload-time = "2026-07-23T17:01:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/d3/a2/2b7633fe5a5a2914900933393c315e9bd86e8fb7bbbe328d3a220eaf2027/comma_deps_eigen-3.4.0.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:dee9eb6c6c7e58201d7a36611857b7b3f3ba70f888ee07493fef2ea41d0d2cae", size = 2275898, upload-time = "2026-07-23T17:01:52.179Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b4/bfcd0e8b1480059e94f978a85497ebffdd33d59a8272a68556e23ed7d1b8/comma_deps_eigen-3.4.0.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:196b9019947552464cf72c7c4659ade6d8bd6733e0bb063d910b168cdc0c2c75", size = 2272667, upload-time = "2026-09-11T22:31:53.434Z" }, + { url = "https://files.pythonhosted.org/packages/6f/23/02407262cc7cb6509bd95b237535b3b32e6447b24abc32200aed6e9b4745/comma_deps_eigen-3.4.0.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5f18b5ae2b007f5422ce67f1e1e805bb07404885d3a8718a0341daca748a0980", size = 2272674, upload-time = "2026-09-11T22:31:57.274Z" }, + { url = "https://files.pythonhosted.org/packages/40/43/a5001078c02a5f6c28e26e3d0458a7e53826d77e9945f3196921df620a41/comma_deps_eigen-3.4.0.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:e07754a4f6062285545f70c597321a5736e193aa5a600ebe189af9e43ad965ff", size = 2272674, upload-time = "2026-09-11T22:32:01.084Z" }, ] [[package]] name = "comma-deps-ffmpeg" -version = "7.1.0.post98" +version = "7.1.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/59/4899ac0fa54905f43e237fff122008d6c591918b41e36880eeb18cd6279c/comma_deps_ffmpeg-7.1.0.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7816c5adc9c6a7462209ccf1d023c42d7e38a4eee47aa2f43d45dfc8320063f8", size = 7326312, upload-time = "2026-07-23T17:01:55.975Z" }, - { url = "https://files.pythonhosted.org/packages/29/cb/6e047c19c39977c5ae322ad698b91d8d9fce43314cb86563de91bb161982/comma_deps_ffmpeg-7.1.0.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:45ad401b4058e3f7efb8d6841e1f187e8c265e942e56e7fb01db1ba9096e6b78", size = 4437675, upload-time = "2026-07-23T17:01:59.971Z" }, - { url = "https://files.pythonhosted.org/packages/76/3d/cda4b19fa5a7b26921a518143c94fd3632030a34b157cab6d6f10f2c86bc/comma_deps_ffmpeg-7.1.0.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9e7034739d45a45254c4200a555d343b22ce117429bebc2de2c1b05f050bfc8c", size = 4681499, upload-time = "2026-07-23T17:02:03.963Z" }, + { url = "https://files.pythonhosted.org/packages/90/10/1407eb09f39d036c2c4d101c3e429f69059a20c78197669a2b10023ac30e/comma_deps_ffmpeg-7.1.0.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4b5074ae64ff44f32e5d2b44ea577cafeee5279e6f1029cc05ab74429f72a64f", size = 7326617, upload-time = "2026-09-11T22:32:04.765Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3b/770c32c1a232dc73bfb43f5f5834ecb2ff8fe55ff627513e1ef458214c7c/comma_deps_ffmpeg-7.1.0.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c0da5b62987de3375d1f8fa2e68d85d1a4a11bbb19a42235ae1f907f372bc364", size = 4437978, upload-time = "2026-09-11T22:32:08.792Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c8/a17c8c293b64ed75074510d9460135b2875ac1832028f950d8f69982df53/comma_deps_ffmpeg-7.1.0.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8756f18e5b1fd760663dfed3b0762fd6b6a5f5e5acfb695e64d06605d2d9ce68", size = 4681802, upload-time = "2026-09-11T22:32:12.583Z" }, ] [[package]] name = "comma-deps-gcc-arm-none-eabi" -version = "13.2.1.post98" +version = "13.2.1.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/e5/a4cd9faa80bf419c6a7052c99dfe565c283a5c966e90ce35b1b4040b24b8/comma_deps_gcc_arm_none_eabi-13.2.1.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d0e6991b845636ab19e46199bc5cb9dd056611bc6b93c6bca4f2bb5002783533", size = 15238810, upload-time = "2026-07-23T17:02:08.588Z" }, - { url = "https://files.pythonhosted.org/packages/5b/81/690ce48945aecf58e475cb728a8d2f6c034493afd87b5b0381a85dd324b4/comma_deps_gcc_arm_none_eabi-13.2.1.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:41fef00d033e0f6c12e1748829d95e53942826e0085f57d918351b2de69530d7", size = 17367240, upload-time = "2026-07-23T17:02:13.637Z" }, - { url = "https://files.pythonhosted.org/packages/41/a9/6af914145bd5c9ce3468a95500558bdc0a438f69700daedd37535945294e/comma_deps_gcc_arm_none_eabi-13.2.1.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:ed3630aac06b3a1db78ba5a29a33b900a51dd1c0b37f86cbfe3c1591993178f8", size = 16941137, upload-time = "2026-07-23T17:02:18.976Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3c/d106a7edbb6e832974fde089c04282da9e0d035e8de1b1b5c6dbedb9deaf/comma_deps_gcc_arm_none_eabi-13.2.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ae7247a36c61a7c3864ac052dadd6787235ffc04d0b724972968ac57164fde00", size = 15239169, upload-time = "2026-09-11T22:32:16.377Z" }, + { url = "https://files.pythonhosted.org/packages/b5/85/3f97e71022055862e0a5f47c960eea18518a750da62ef7f6125216ec0e9c/comma_deps_gcc_arm_none_eabi-13.2.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:dd8eaac31e11dd291b4ea37f14c43e53e5b2b2b059207deb44b5caa3be2c6d86", size = 17367602, upload-time = "2026-09-11T22:32:21.355Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b5/4d8294c7e081b93363df0b6773be0bb2632d53c52e4fc89e64f51ab51ea5/comma_deps_gcc_arm_none_eabi-13.2.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6006a1073292d56d3cc25f1a236fa4da6039028d9c251bdd66747f5d5013e020", size = 16941494, upload-time = "2026-09-11T22:32:25.886Z" }, ] [[package]] name = "comma-deps-git-lfs" -version = "3.6.1.post98" +version = "3.6.1.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/27/ecfda511eb334822d9bc464ec2d9b74d3c553784811a885baba34a27eaf6/comma_deps_git_lfs-3.6.1.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:259b1f4859bb3ab20fdcc012be0a9868a3649e1087d5cec07eaade7adea44c78", size = 4685104, upload-time = "2026-07-23T17:02:23.67Z" }, - { url = "https://files.pythonhosted.org/packages/00/a5/9631b4a676279b353f82d4e2da62eb567c70fff628133a62d7f70fbf5924/comma_deps_git_lfs-3.6.1.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:60d39254138b2c7c3f15cc512c14504c11881e79885492321e1ffc3ecb840f93", size = 4485276, upload-time = "2026-07-23T17:02:27.751Z" }, - { url = "https://files.pythonhosted.org/packages/09/5a/7ef6bc209d8ec15c40b1f988345e2e59c535a215284366916422ba0d0c30/comma_deps_git_lfs-3.6.1.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9586057ca9c6e77e9068128f3e96f0ca03db29a757414b1b251bab4ca31ee6b8", size = 4889582, upload-time = "2026-07-23T17:02:31.454Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c3/75c2406e52cb5129d8ce6b79f80a610417d8c8729422c1e80b14b6771e69/comma_deps_git_lfs-3.6.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ba61d0c194725aee5644731f229ff36f56a00dfd450f8226794008ab98a93144", size = 4685121, upload-time = "2026-09-11T22:32:30.047Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4f/17fb16d2de5d858ab95fdd09fd652f25d8a698b35df3e4ce24589dfd9565/comma_deps_git_lfs-3.6.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2d815aab8aa153a6536f538a65872ac64aefe8c62da153f9b0e8bba23a98d5a8", size = 4485292, upload-time = "2026-09-11T22:32:33.868Z" }, + { url = "https://files.pythonhosted.org/packages/d3/30/ae0def1cf507492591fb2852066dda163fd28d0e1cf52ad76c20ce1fb358/comma_deps_git_lfs-3.6.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:a78ca419dec75a1b99229ac40c086c3236b86ff8585740c6b34c2ccea95b1389", size = 4889597, upload-time = "2026-09-11T22:32:38.084Z" }, ] [[package]] name = "comma-deps-imgui" -version = "1.92.7.post98" +version = "1.92.7.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/e4/b9f4b68973bfd529314c28fcd87cb2f52b5dc7d9fdeb3be2d3d15b7cea25/comma_deps_imgui-1.92.7.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6fddf76138b1e54fe33e9f5ea3cbcb650f92fef8fbcf7e5080800ea851d68b98", size = 1688011, upload-time = "2026-07-23T17:02:35.416Z" }, - { url = "https://files.pythonhosted.org/packages/7f/46/92030abf6e42e9813f144d10bcf541b39a246c5ca2d63d049478deac650b/comma_deps_imgui-1.92.7.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9f7eed0f759e59afcf289967edb3d94c48a38fe97527f2909dbbc70a850dc2cc", size = 2522785, upload-time = "2026-07-23T17:02:39.092Z" }, - { url = "https://files.pythonhosted.org/packages/7d/57/d41e76559a553565413976695fb63a768d3446d12eccc9e736a12b53e662/comma_deps_imgui-1.92.7.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:07eeb105cce73ec3b27789501c78dcd059d75e736a99f1953358ef5097e7036f", size = 2655476, upload-time = "2026-07-23T17:02:42.925Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0f/29f3dc72d8b745f8db30245502c7d5626eff5d244a4c02c4e89bbc8497ec/comma_deps_imgui-1.92.7.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bb693b2e9f3aa5900b8884527409e73d6580c8b05bb42e1ec1a2897f4f6c05fc", size = 1688025, upload-time = "2026-09-11T22:32:41.73Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4b/abf18a8cc4527c6cb1d8f8dbec10b02e28746b303850d90fe0af5ba82a9d/comma_deps_imgui-1.92.7.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1b2737fa6f47ad00835828ff663eae7150519e0a6cc7df9e0b336a48c24032b6", size = 2522805, upload-time = "2026-09-11T22:32:45.498Z" }, + { url = "https://files.pythonhosted.org/packages/17/b8/d5fe737f156ca1dd1797f9ed664464b43fbe7712f9b61271fbe6315ed539/comma_deps_imgui-1.92.7.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6554b6ff6ba818038ac8a6c51bd71afbb285c7b3f1c0c31947298b17db8f111b", size = 2655501, upload-time = "2026-09-11T22:32:49.008Z" }, ] [[package]] name = "comma-deps-json11" -version = "20170411.0.post98" +version = "20170411.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/f4/50411c9134a8347831a72f90318b7b7d91ce566e63575b8a4a821be50ca4/comma_deps_json11-20170411.0.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:20d666897062487e4cd93b8e4eb9c53ecabf706864be8d8cbb60a56f0113c452", size = 34034, upload-time = "2026-07-23T17:02:46.595Z" }, - { url = "https://files.pythonhosted.org/packages/1f/54/0c87fae682ee52e6aec371336ac980921ad34cedabb69e576cc9f83c40a7/comma_deps_json11-20170411.0.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9b4a03909609b832dc99b06503fb8c77419399a99e0db982d4c3f51f1563aa73", size = 41848, upload-time = "2026-07-23T17:02:50.039Z" }, - { url = "https://files.pythonhosted.org/packages/7b/71/dd100992e13f2c7a01f68eebcd1e3cf43f1d169e4b80b0577f330e5f5c12/comma_deps_json11-20170411.0.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:1da9030908f3a6631a0a254f493c382ba061c2e8ddb280a30d64430af29f4638", size = 42602, upload-time = "2026-07-23T17:02:53.233Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5c/8bed22f3118f53ae08763c6dfc755d08826c750ac21864d60a4710ef3183/comma_deps_json11-20170411.0.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cb255b3c010e83f8361869f7cf8b1f585db5e0dabc92b41f3132af49838e981a", size = 34039, upload-time = "2026-09-11T22:32:52.45Z" }, + { url = "https://files.pythonhosted.org/packages/6c/be/51c3dd8f80ee0a968a90b4737e0362e7b621366c24c9c55613386ba0aa14/comma_deps_json11-20170411.0.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0f7764609db411d98c540d985c289a2c37f282fbf2a75e2503017f1243546653", size = 41850, upload-time = "2026-09-11T22:32:55.711Z" }, + { url = "https://files.pythonhosted.org/packages/79/73/7f893e3a65e82c2453806c39e79eb4e2a8074f358cfa20e1b66e46e9a6b3/comma_deps_json11-20170411.0.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:a309d401dd22b758455dafd3c28b72c4bd88c7482ddd70fe8653e53efc745a83", size = 42604, upload-time = "2026-09-11T22:32:58.866Z" }, ] [[package]] name = "comma-deps-libusb" -version = "1.0.29.post98" +version = "1.0.29.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/fb/f7d342a8f785fc1c0fd5d6883e1a5a7d424a1899b888f78f9091f4b98049/comma_deps_libusb-1.0.29.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a10b82a946c33c23152cee3e330ce76d398ddce1f38fea62e33773bef8f56164", size = 102339, upload-time = "2026-07-23T17:02:56.567Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/1b21692cc03078219a3946aae56086a109168a4b4dcfba3a22ce1cd01064/comma_deps_libusb-1.0.29.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:39567eeef6170ece389526780f90c3b75cbcfdf427f648f6b1539c203f7387a8", size = 94431, upload-time = "2026-07-23T17:03:00.01Z" }, - { url = "https://files.pythonhosted.org/packages/49/d2/d93aac76b94ae87f7a37ce88f2e7e1184e19e67d10c068c1aac209075450/comma_deps_libusb-1.0.29.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:5e5b86be94b4a355c6be933ed21586d01b1d7ba597298dafd33b871a1ae66416", size = 93462, upload-time = "2026-07-23T17:03:03.218Z" }, + { url = "https://files.pythonhosted.org/packages/ad/81/865cbc69c8f182a020f4fb85c70b4d4e1b2a636eedfc34c85490472c1e56/comma_deps_libusb-1.0.29.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7a8afc81bce03f3d6386613983a23a303369bed19593e84d0c744e8381087cb1", size = 102343, upload-time = "2026-09-11T22:33:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1a/d07a980d8132c58cc328974c0566c8263fcff9b60e053129030fd849dd2c/comma_deps_libusb-1.0.29.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:56661f2674fd41abb5337a435c032552ecd7318857d598f79c4fd6cdddd727d8", size = 94431, upload-time = "2026-09-11T22:33:05.488Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2f/19227534c7f7cf4850ef73df16ec5feabd80e23edebec648877a5b82eab6/comma_deps_libusb-1.0.29.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:450c9bfaad634931c336eeea8369d4ac8db78ba092b3c5d7b70c098d5634b050", size = 93460, upload-time = "2026-09-11T22:33:08.701Z" }, ] [[package]] name = "comma-deps-ncurses" -version = "6.5.post98" +version = "6.5.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/d4/03e62b2a0be92ad420653ff1cf4396de9840c4c59cdc6e000ea5614f7744/comma_deps_ncurses-6.5.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:22ade1596deb18538d4bf59708aa2747c28c2e0e7048f13f5bfce3e5588f7417", size = 264921, upload-time = "2026-07-23T17:03:06.576Z" }, - { url = "https://files.pythonhosted.org/packages/1a/ab/295b428ef473dbe0d7088ff02daf5ba17b924f3b915ac69a8a9c45a6eb2b/comma_deps_ncurses-6.5.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6724d3e8c1f2e59d2475f7588d86494bcc4001e993fd7dff4c91ecb046edc97f", size = 260844, upload-time = "2026-07-23T17:03:10.329Z" }, - { url = "https://files.pythonhosted.org/packages/cd/db/75afb33eaa86425d9bee68153f6141be2645cf5699b2cab5a7cbf7a36099/comma_deps_ncurses-6.5.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:d85e70e98f4b0a969d63a4a61a1a5b85db3c648ea58422401b55f386813f7d12", size = 248352, upload-time = "2026-07-23T17:03:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/94/43/6a0c4cec1c245ec0dafa8d2c6bfa433b596153f7efa0d58f6b80c0145a74/comma_deps_ncurses-6.5.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:aad15d3cd54e6930defdad9f3c968b3f115ea634369b75b62b1457eee4789208", size = 264908, upload-time = "2026-09-11T22:33:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ca/f59ff912be4f191f6e3416548d880d662b66237e37bc8b4026af2aad2244/comma_deps_ncurses-6.5.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a8d5822ac1a1cd5bb16e7e06ba702d6de6fb584b5727190583b6d6d983cf8530", size = 260849, upload-time = "2026-09-11T22:33:15.623Z" }, + { url = "https://files.pythonhosted.org/packages/36/f8/7406248610e7862cfca0e2c5b447baa565c504c7ba72ce19e811240e73d6/comma_deps_ncurses-6.5.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:7eeb250ed2bf34d2c69098d42ec5d63e333a2e3a802911a0e1036e855b547b5c", size = 248321, upload-time = "2026-09-11T22:33:19.032Z" }, ] [[package]] name = "comma-deps-raylib" -version = "6.0.0.1.post101" +version = "6.0.0.1.post103" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/90/e289acd1725d71c792c33399422f1052c4b0c38aeb4222a866d30c4a2cad/comma_deps_raylib-6.0.0.1.post101-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa69d5093a92d7d2bfd2714a1afccca63b94d4c55fae88e61b80e8841de6a6cd", size = 1885392, upload-time = "2026-08-27T18:24:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/a1/17/12977631f6d86d1daa4f67a310cfdf2783ba288f42d444d6a89b2297a0a4/comma_deps_raylib-6.0.0.1.post101-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c93ff9b45df414620b3011280da77151764c027e2f95d384fe255615d78872cc", size = 21707644, upload-time = "2026-08-27T18:24:41.282Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ca/ef33aff790b37dfc925f94fbb3a86da19f67929c15b3725ddb18afb91e96/comma_deps_raylib-6.0.0.1.post101-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8f2a1ffe5f60cac06170b144d6ce84925c91657e7ada3ae35bc5df6ccbe0b461", size = 20722616, upload-time = "2026-08-27T18:24:45.803Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/21e3be3ad1e2382b9ecab7d836e7ca19ea0959fe714d539a71c6b87ba510/comma_deps_raylib-6.0.0.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d8bef63b7e49bc8704eda9fecdefbff01d7f6c9027069cb1b1f5150cac077ed1", size = 1885397, upload-time = "2026-09-11T22:33:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4d/cceaf969d771af8870094d06bb84dca805f9268584a9a3c134dfee2307c7/comma_deps_raylib-6.0.0.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d9b9bebb39167442bacf627958d57ae95e452550ced1b68337cbf2a73375856a", size = 21707647, upload-time = "2026-09-11T22:33:26.533Z" }, + { url = "https://files.pythonhosted.org/packages/27/ce/e8b941bcf0b213ca9cb4d8cb69b18398b4dad77923925d80bc3281837461/comma_deps_raylib-6.0.0.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:942ec942ec01005f05584eede1a6add483043e8e8f764e68cfebb7c21c443b89", size = 20722617, upload-time = "2026-09-11T22:33:31.158Z" }, ] [[package]] name = "comma-deps-zeromq" -version = "4.3.5.post98" +version = "4.3.5.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b7/b0070e091dae4be2cecccfb2921167b568d0e7bb9ea600b5814603e0590f/comma_deps_zeromq-4.3.5.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0ecde97133d657024bf99ac7302130905a131dabedbc8425b6666b2058ce6acb", size = 815150, upload-time = "2026-07-23T17:03:29.517Z" }, - { url = "https://files.pythonhosted.org/packages/2f/9a/d6a381b079516eca1b8a86aa3e972e550a48ff2f069ccc722b118ab53d60/comma_deps_zeromq-4.3.5.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:110a628ea440ea75707ad29fd90341d300414b0092137b1d43e8f19d100cf2fa", size = 833389, upload-time = "2026-07-23T17:03:33.25Z" }, - { url = "https://files.pythonhosted.org/packages/af/d7/504649efc8dbe8ce4c0cbec085178d1c3298f6950bd4bbca1683a90c49ed/comma_deps_zeromq-4.3.5.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cd16a515f00f5fb679c2883970c2e7f446ad84e65d7fcf045d325952f9cc3607", size = 798894, upload-time = "2026-07-23T17:03:36.788Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a9/9013d207cca0f1d4dda65f1babf16e9e2e0b5fe86259bf994d15cc6e6024/comma_deps_zeromq-4.3.5.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:66d4cc63e45bf5864e68d6a9528ce90419f4c7210250970ff58b3919985c0929", size = 814878, upload-time = "2026-09-11T22:33:35.467Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/522bdc7b93d9c84b2600e9ed84588c94acf698a8aa3186e304c6d54b4371/comma_deps_zeromq-4.3.5.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d25d7b336707f39a6c92f0b01b40ca761d067564b39c6c56a6a7f4cca7fd512d", size = 832994, upload-time = "2026-09-11T22:33:39.03Z" }, + { url = "https://files.pythonhosted.org/packages/e4/7f/e79ec0b13146d9aea6004c06bcc37264bda973a5a71248d32dbcc7ac29b4/comma_deps_zeromq-4.3.5.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:bb05f93a91b140eb54129bf68824d00a6f93802071957062995cbbc6184f70d2", size = 798535, upload-time = "2026-09-11T22:33:42.538Z" }, ] [[package]] name = "comma-deps-zstd" -version = "1.5.6.post98" +version = "1.5.6.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/66/fd1098b4514e759d85e19d604d446ecec1f67e2f452df9e280d01a2449f7/comma_deps_zstd-1.5.6.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2b6acdd50e71ec67a1426423cda5116a7cb43900dd2e4fca2cbcbb7d588be171", size = 1065140, upload-time = "2026-07-23T17:03:40.465Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ba/1d61aae97577bbf13c9c02e7e69d4c9391947d8d0d09ca4ad78f2e3d0faa/comma_deps_zstd-1.5.6.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:04825faf902754a0945ebac374d01d676b7e5f98a68e460452319de509b369f3", size = 1006145, upload-time = "2026-07-23T17:03:44.144Z" }, - { url = "https://files.pythonhosted.org/packages/a9/22/94d164407b579090eb3aceeeb63fbd8c540f6972a11e5b72fe3ca3139333/comma_deps_zstd-1.5.6.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:316200a52c9ac1aeb6b22030480ffebaa1d91756c18a3f3cf216368a5fb35bfd", size = 1030359, upload-time = "2026-07-23T17:03:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e5/17e340fd63173767d7beae9228c2e227352ba880ced754203f9b5096f223/comma_deps_zstd-1.5.6.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37bd1c5b8bfac9d19095f741030a73a58663bf0aab93a99768f53db67fcd09ac", size = 1004948, upload-time = "2026-09-11T22:33:46.046Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/96a88f630732dd83f0d01adf7707b369a055eea6dc4612a30ae6edaff8fc/comma_deps_zstd-1.5.6.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d9a6b6b6d4ecdfd28d4843f8dcd42dfd6f4886672cc90c0957798f9c0326571a", size = 956886, upload-time = "2026-09-11T22:33:49.654Z" }, + { url = "https://files.pythonhosted.org/packages/4b/64/23cf37ae4d915ad9f7b341b08cc0435aaeeef543185c86bb5b860c87cad9/comma_deps_zstd-1.5.6.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:d7bbc77bb174475f30bde69d7451baa47ddbe12724e586ac8874d799c7368101", size = 980997, upload-time = "2026-09-11T22:33:53.384Z" }, ] [[package]] name = "contourpy" -version = "1.3.3" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/97/ba/01bde9753bdbba04a6da9d2bff3881f021bc708f654655e95fae26d3b3a3/contourpy-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:186ba929df36d61b6127da2e89cd1357e4cb79aca647381ab1a6feb0b152877b", size = 297302, upload-time = "2026-09-11T19:02:45.103Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f5/c5bf67522d49a2222f3154fe46879451d26f0e35ece41770758a64ea78cb/contourpy-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c76f3a5318164db7d9401132fc1b5364b784c613c93fa506e3b5e6d1bf353ec8", size = 284921, upload-time = "2026-09-11T19:02:48.049Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cc/cb989599eec12fda312e127eb8e04a8b21a7a6c94bf7ff1bc68273334a42/contourpy-1.4.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43c3ccbb32c6294b183dcc8e8c46dacf5ecef809497e3d48a5be298eef185ad0", size = 356332, upload-time = "2026-09-11T19:02:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/6f620d7602507817b0b4c21dd790ed68688f1f4ae9c29aacf21f08db5cf1/contourpy-1.4.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86d05cec773c9507a3950122e0e40ce77c23c75ceeb2fc189514e71893cbb34b", size = 405878, upload-time = "2026-09-11T19:02:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/693c6d6bdece679f0953775eef3a1b66d56be55478d936e7deb1f3004422/contourpy-1.4.0-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2deb580178ca19437a84bd77e4bc2cd91a8ccad212413a83c273900868b5978c", size = 408296, upload-time = "2026-09-11T19:02:56.528Z" }, + { url = "https://files.pythonhosted.org/packages/78/f9/b6831508960d559581c448532ba4df312217072975486dbf2b24fcc5b76f/contourpy-1.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:875f42444c9cf48d56f724f2637e60d0f73b3b12c9041e1484580a233edf9591", size = 383243, upload-time = "2026-09-11T19:02:58.638Z" }, + { url = "https://files.pythonhosted.org/packages/f2/21/52903825a0ae7bb625e8bd30a09816d4c3c5d6174a469c10a616166cf780/contourpy-1.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f863c6100bf926cf47d13f3cd75f9bb8ebd98aaab230eeb4468b91f98f39a6b3", size = 1357306, upload-time = "2026-09-11T19:03:01.036Z" }, + { url = "https://files.pythonhosted.org/packages/d8/79/d68b1ce8539e4071518fed9523f558398f34dcd078b8927b109c72dad2ef/contourpy-1.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3863ef2e2b13fe93f8c0ebb08ee400cb07153b8b0e91c5acb26e4537f283634f", size = 1427243, upload-time = "2026-09-11T19:03:03.866Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/b75a10e9d0616b97a30de277b6dfe83f1879880854c7330337309530eb48/contourpy-1.4.0-cp312-cp312-win32.whl", hash = "sha256:5450f091ac1be0be3ad3a2a3b3f23b5e443e78c670ced4fd347d626f92a28fd2", size = 347345, upload-time = "2026-09-11T19:03:06.192Z" }, + { url = "https://files.pythonhosted.org/packages/50/a9/dab08786bb4d77ef9a046b3c1be923be4e73c5d07d91f5a54e8ea9b09e41/contourpy-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e697d94e69f499ff6bebb899cae97a58d5d14f0e1fe9568b43a0248d2f9af8c", size = 234083, upload-time = "2026-09-11T19:03:08.122Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/3ba509755a970dbde5948e7142ac21f266be72f38017cc4087a05fdceee1/contourpy-1.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:0c7a4c2716a4e98342221954416a836cca77c996c14ddbf22b5f01d5d93ca09c", size = 559521, upload-time = "2026-09-11T19:03:10.047Z" }, ] [[package]] name = "coverage" -version = "7.15.4" +version = "7.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/f5/deb1a27aa20746c0278ac998c4179e272004699b2d33959ce020c5ac1615/coverage-7.16.0.tar.gz", hash = "sha256:077f0964087883176ff6ab9b074694cae29f8c708273b13ca62c183c6ed716cd", size = 945620, upload-time = "2026-08-28T21:54:37.74Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, - { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, - { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, - { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, - { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, - { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, - { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, - { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, - { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9c/8d2688694f53dc0b0f0e4783c7eb3c4bb1e79beaf1411879f6dabedf4607/coverage-7.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1c77c3579ac42798f8b7eed6d3dd258debacca32c8753fc8a1f6eaf1db644f5", size = 223194, upload-time = "2026-08-28T21:51:27.767Z" }, + { url = "https://files.pythonhosted.org/packages/ca/11/f002163dd688aa3fa49ac6a424b7c2705c7fcf80fba18ec9f586d77827ca/coverage-7.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f81cb1554c3712e41649ed5dc98656b50b958e4da12f0f5adb681ce3db92831", size = 223553, upload-time = "2026-08-28T21:51:29.46Z" }, + { url = "https://files.pythonhosted.org/packages/81/65/f9d469e97c4554372a710650a109004a2434dfc56f577142e5d6057fa0cc/coverage-7.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e701938ec9081d3e400a0c9a9a8ae0f7ca44214741daeac4454b1c6ef6dbd19", size = 255054, upload-time = "2026-08-28T21:51:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/95/29/dd89fd39af1a3b6e9a9c3eddeaf03f6376ba517d43d6cbf8b519177e2a10/coverage-7.16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:719a3feb6220dd32ed932d4c3676d17fb8739e2643b29c0e7c3af400ff80ac44", size = 257790, upload-time = "2026-08-28T21:51:33.374Z" }, + { url = "https://files.pythonhosted.org/packages/0a/64/208d26cedc525d6b5db9c492cf9130784c42d9eb08d22badaa7b806005ad/coverage-7.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87771ecf986cff55e87413238cd5e4f54d949c2074bd6fc1657d26a56314ee24", size = 258904, upload-time = "2026-08-28T21:51:35.096Z" }, + { url = "https://files.pythonhosted.org/packages/1f/98/28e2752aa9a8baee5798edade9c95602ca200f4e7eeb503eb64df42e5921/coverage-7.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47d5e1fc0b321c8308a2aacee0497c435b08acaa629b7059798fdf6fc3006352", size = 261165, upload-time = "2026-08-28T21:51:36.744Z" }, + { url = "https://files.pythonhosted.org/packages/eb/77/fa6ae699a0ea2bc12acb38a85d96b786fea0f833c12b5756056350e0e547/coverage-7.16.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01b18b8a6c9cec8d5f45550e2501426ed982cf2c35016b0acd2ba9b5d8b2fb06", size = 255416, upload-time = "2026-08-28T21:51:38.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/c8/5ee46d1de7d34cb00ba08b5c50da1971114dbc09ca9898ccc32975ec74dd/coverage-7.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:32c56b5b47c50635081445ac404dd08c2d591b9c837c22570aa9e182c3b42cd4", size = 256825, upload-time = "2026-08-28T21:51:40.27Z" }, + { url = "https://files.pythonhosted.org/packages/15/f6/d59e1c0693ad48855fe20169fbf6ee5befefe5887a7fabf5f0bcb464a2dc/coverage-7.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6ad3bbad240ab937512156bc944fdee63ac4dd34a7558a3094548fd4c1150c02", size = 254970, upload-time = "2026-08-28T21:51:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/df/7b/b51bbe05b3a7565927fccfb1be42b8b3c1f4ab15e53d91b303e9923969aa/coverage-7.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4c1f16d5555a195295d0dc9c902612270e3dfed6a11f3bf7bc470b7b6a79ed3c", size = 259039, upload-time = "2026-08-28T21:51:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/d513f816456a8a43c1859abe88a37d01d7d2515b6c3e24ebb3c9b1dd44ec/coverage-7.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f6c9c21a8bf0d19788f3c5f3e020c90317a0a63ef60521b376003801e21250fb", size = 254539, upload-time = "2026-08-28T21:51:46.733Z" }, + { url = "https://files.pythonhosted.org/packages/dc/54/5542190ceb97e0d1333a4ce0c8f95b2ef2efe790f1ad018a4b61766f849e/coverage-7.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f20145a9eb5bf1fd1dde3c0bc2af2e7c22135ab07ca6284d6ada7cc3904c4e", size = 256410, upload-time = "2026-08-28T21:51:48.363Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/78643f361ff6bb5b2ade90f8bfc8395fe9ca367a18c101f8991215b4c65b/coverage-7.16.0-cp312-cp312-win32.whl", hash = "sha256:916cf8d25c1ce148f7eceb1d45afc9724841200110adc4e53250391852debd91", size = 225239, upload-time = "2026-08-28T21:51:50.22Z" }, + { url = "https://files.pythonhosted.org/packages/67/61/8e76b36c36b1a033dc933dd2480db96b04ce3be975793ce3fad122e7174d/coverage-7.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:78f8b56261d608be102c62edd3a60b66bcd0b581f3f86fdcabaf8b8d95adc950", size = 225775, upload-time = "2026-08-28T21:51:51.912Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f3/bb4787a4b81c1792ca69b502f5f730dbbb609f73fed552ab074c6b92cb8b/coverage-7.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:577c2ac8c0036f6f8edd3a7783a9e67302b17771d1abf0fd2ed246e3158be51b", size = 225159, upload-time = "2026-08-28T21:51:53.667Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, ] [[package]] name = "cryptography" -version = "50.0.0" +version = "50.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, - { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, - { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, - { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, - { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, - { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, - { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, - { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, - { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, - { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, - { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, - { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, - { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, - { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, - { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, - { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, - { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, - { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, - { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, - { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, ] [[package]] @@ -334,50 +334,50 @@ wheels = [ [[package]] name = "cython" -version = "3.2.9" +version = "3.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/de/db48b8870e766cfea809986cc50c1e986c663a9ab7bafd0ac1a2512c4a26/cython-3.2.9.tar.gz", hash = "sha256:d249c9022ab13286b17bd66f30609e800c5f95efeecb06168990c7a66cecde6c", size = 3293493, upload-time = "2026-07-24T06:21:21.867Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/d8/4981ef716ad0e3ff0d3ef383aefc6b03c4a88dee33b272bf8e0d833001ca/cython-3.3.0.tar.gz", hash = "sha256:eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8", size = 3727515, upload-time = "2026-08-22T05:16:39.493Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/37/c74d842306c8fe381c415b37460d5e3086a820fac72b8ff5cb48513ccfcd/cython-3.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:114b2dee0fa1daa48a59574d848da0ff1b6bdb725a755e9b92fad14962e1ff8d", size = 3009571, upload-time = "2026-07-24T06:21:52.534Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/f7c42b161edd585e3ae556fd62a2c72cd80a6ed527a9907f0c5c6fb060de/cython-3.2.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5cd9c5f138cb052130b40ad3b6976d2180c35348410995812678f4636bd8f94", size = 3183562, upload-time = "2026-07-24T06:21:54.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/1b/c04520ac7f3157aa12a69b632c16261170dac9fab6c48608cc004b8f1b17/cython-3.2.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23e80bc885c599e72072e18d0746df82d394b73100c1e153cda7359e6e59fe09", size = 3354811, upload-time = "2026-07-24T06:21:56.72Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/4ce33235a25b19fcd51dc639f0f403b783a3b7f9b1934eade0d993fbe029/cython-3.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b1fd5a9c03f72a18618668a8e90d569442ed742f910e3ad003dcc9348e9598b", size = 2778077, upload-time = "2026-07-24T06:21:58.7Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4a/342312c5fe021c8e0c386e1915d138e0902c48ae179b0374ab04773a8831/cython-3.2.9-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:944dc8747f640b3527649c566a5fc75ee0c15e80642ea2fdae4fe6378e1a9d4a", size = 2899729, upload-time = "2026-07-24T06:22:24.877Z" }, - { url = "https://files.pythonhosted.org/packages/f0/62/ea919ee426cb4d435ec8155e1ee6bcbb46b20d8f070527191b59769d4e7f/cython-3.2.9-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4b871ad97dd7fb1cbf56f6238c54423febd310afc1d9d9bc70c69c89b7ce57fc", size = 3226650, upload-time = "2026-07-24T06:22:26.947Z" }, - { url = "https://files.pythonhosted.org/packages/18/02/057b4f63e2ced8c3cf217c4e9fb544bfe48145f493347c7ca3f51607526c/cython-3.2.9-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9b6ebc6c74b4318eaa4e51e520dc8b95ebc7b262953c3ecb24131104681f14e", size = 2881919, upload-time = "2026-07-24T06:22:29.318Z" }, - { url = "https://files.pythonhosted.org/packages/64/e4/e158793ee3de7e4417ba17e7ff1015d6e2cf557cb485ad270b2446c9d1c7/cython-3.2.9-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:92989da161a7d18a7ad4baebc49289b2b77556d5a94916f90140ba26aecf6892", size = 3004702, upload-time = "2026-07-24T06:22:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c0/d5bbbd743ab4feddb24a7e823b34c3ec4ebab91ff503d16743a4e7ce106b/cython-3.2.9-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e75ec625d8f8781ced690b7a2f5c2d138067711cf24bb8fb68c872c30c2fefe5", size = 2902695, upload-time = "2026-07-24T06:22:33.525Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/80850817395985259f135baa510d9186d3a325df81cd1862060bba977029/cython-3.2.9-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:2b1756ddc3bc0cd4341a515fc420c3e25e13c249f5537159b3fb0bff8d19e55c", size = 3241554, upload-time = "2026-07-24T06:22:35.667Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ce/6be776814f6cb81751f3da737ed537385738148e1ea99f89fb4637799198/cython-3.2.9-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7d41baea51ea00f9237f75af498577827493bca5e9b45bbd4e351543727e589a", size = 3124337, upload-time = "2026-07-24T06:22:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/e2/48/27c948cbbfe6050994e67a497ae530955dcda7e79084319321c49969e0fd/cython-3.2.9-cp39-abi3-win32.whl", hash = "sha256:61d4abbf84f77c8d19361d05d9f51d65d8d95e74f736eae55fa1aed8a1430469", size = 2435609, upload-time = "2026-07-24T06:22:40.005Z" }, - { url = "https://files.pythonhosted.org/packages/17/ef/cf0e1bd7542296f1752be63b027f90271448d8c8062eac66d8e44a79b883/cython-3.2.9-cp39-abi3-win_arm64.whl", hash = "sha256:57a6a78d14f7dd7d6062d9bca694e2a8c1c14113b6ceceea076abcd1161fdc5a", size = 2458025, upload-time = "2026-07-24T06:22:41.973Z" }, - { url = "https://files.pythonhosted.org/packages/00/ec/e61deec9bcfbb0e1b36f8b5ba75cb44644419b4bfd0fdd666bffd21d9579/cython-3.2.9-py3-none-any.whl", hash = "sha256:a2b0e87f6b80790c929308ca0831d686f7a180feab684fe8cd4a4380bd96aaca", size = 1259272, upload-time = "2026-07-24T06:21:18.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/95bad838a80ac52c9e982dad00bd9a0b2bad57fb4c688e5f53ac3ef65ff0/cython-3.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03bc5333932f5dda3ba9315298ecdd21daa1b58410bb1f8ce04c78ec8337130a", size = 3143472, upload-time = "2026-08-22T05:17:07.956Z" }, + { url = "https://files.pythonhosted.org/packages/91/8b/53d4a84de853b39940a0e35a6a2a9ed5f54cb05468daee95bc0fd1c2a178/cython-3.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e321ae700995a16dc3055ada06ffb8d61e1a7434e5d0e811547a45ac1015ebd", size = 3258974, upload-time = "2026-08-22T05:17:09.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4e/6b1c5a4e6bbe1726104de007aa2fdf01a3e2e386b4ec93c7be5f5085d53f/cython-3.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:428fafed98ea26927000a287b4dfc9ef07339f56656a5329a34eaa593f79a4f8", size = 3412225, upload-time = "2026-08-22T05:17:12.281Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9b/cd724d91c500116769bdb853450a2197ba3d640dbbe3b02fc54ebdfdbd1b/cython-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:333449cc0350baedee5a6af27929eac8a71eac4ec59333c45ff476b33c6c660d", size = 2872123, upload-time = "2026-08-22T05:17:14.322Z" }, + { url = "https://files.pythonhosted.org/packages/14/59/bc1a84b434cb5bebb0cd6f50da8f239d35a5c141b20fdeafc2817fd87778/cython-3.3.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:e0d2713d2b292c826bc21dc8732bd9e47628103aa3764180c881e04b3fef95dc", size = 3063660, upload-time = "2026-08-22T05:17:40.923Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6d/542e32908fb421d88354f327ed6450e14240f9825d25393065bc65f4723f/cython-3.3.0-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:169e56fd411f4cd5bba51c82f8239421d547a846099db2b261e4aed48ba9f51f", size = 3358395, upload-time = "2026-08-22T05:17:43.036Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ddaf197bc65b581e1891657940bc4f7cb1f740e822115e828920b3a119ce/cython-3.3.0-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:29f38ebafdf23e3da2516f40c4d065da38bfe002181bf93e2b8cf1262449aba6", size = 3041760, upload-time = "2026-08-22T05:17:44.907Z" }, + { url = "https://files.pythonhosted.org/packages/19/a7/ae5ec3e34d43da846ed4c425734752d83aae0dae49feb929f09c90fc9afa/cython-3.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:75c4ae8a6d3a5ccf3cdaba8ab32e6a8d0cd38e3a476aa7ac12df8f8171a8d570", size = 3156152, upload-time = "2026-08-22T05:17:46.884Z" }, + { url = "https://files.pythonhosted.org/packages/31/44/c60b601fc43f0b08e9d6f14b94e0dd02eb0ca8d60f46e242ace7191ac1be/cython-3.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b94fb5613b9fe34c27d13ec9972dc0dcd2a2155db2902e93921cadc162610a38", size = 3046690, upload-time = "2026-08-22T05:17:48.731Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9e/d735c26ed907563d3365534006acb263651c2d3b87fee804f7a483dd1714/cython-3.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c4558ba85849ab65dc57e10fd0efb13fabd9d3c09981a2566e18dec7cf47586a", size = 3373534, upload-time = "2026-08-22T05:17:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e8/aa7b4f3a28d6e8117c76e2cf78a0df7a503486cdf7243c5b53200c9533a1/cython-3.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:311a016369adfd1e0015c4f9819168fc0e518451d7efb4435c30d65a3a26d52b", size = 3265110, upload-time = "2026-08-22T05:17:52.577Z" }, + { url = "https://files.pythonhosted.org/packages/9c/66/37892a8999d6bbd3f92d691a9701cb720c8ddd6171e16f5148eee6e8cb7f/cython-3.3.0-cp39-abi3-win32.whl", hash = "sha256:90869072e50b7c8904fe1dd7810321ae901fd5637a6eec6646ed9c57f9eb1081", size = 2587733, upload-time = "2026-08-22T05:17:54.547Z" }, + { url = "https://files.pythonhosted.org/packages/19/a2/5f4d305cbd4489d21570e5491ad5c483c478cdab032853e2125c280e3bd5/cython-3.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:dce56c26d388f00a19426371b6926bf2f77c5c03b71d5273e4556c68be98c2dd", size = 2608078, upload-time = "2026-08-22T05:17:56.386Z" }, + { url = "https://files.pythonhosted.org/packages/bf/77/67b0b24e45073a699610e50f00c18474ff9b09ea29ecc95083bdf5e60acd/cython-3.3.0-py3-none-any.whl", hash = "sha256:9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1", size = 1349151, upload-time = "2026-08-22T05:16:36.741Z" }, ] [[package]] name = "fonttools" -version = "4.63.0" +version = "4.65.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/51/d63c7e52163ac14393a35bd14bd7c0da95f8f74be5d7cc988092f9965129/fonttools-4.65.0.tar.gz", hash = "sha256:762ba5431358d0dbd4a01982484a1d494fb267e91f974cdcf20b80eab8560f6f", size = 3674467, upload-time = "2026-09-10T15:35:54.955Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, - { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, - { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, - { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, - { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, + { url = "https://files.pythonhosted.org/packages/58/db/242fa4fce7f632c5f7ab15585343393b25792510c0c32bd218ad24d59f1c/fonttools-4.65.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e844a45c9e5ced6536f184cf1a65b5d65e8f7e711993b413e10500a8223622e5", size = 3097120, upload-time = "2026-09-10T15:33:46Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b6/42fa4d373416675f74446421cf0b2badb82a4245c60745f05f424f75c649/fonttools-4.65.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b30e953de049bf43fc0a63c7d0c44d205c923e4bbf24716aae1518c0e65f977c", size = 2584658, upload-time = "2026-09-10T15:33:48.473Z" }, + { url = "https://files.pythonhosted.org/packages/75/6f/d589b9d62280a846c77a2c383d852c6dcb79ae8aa02bf0fa46c8577af145/fonttools-4.65.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09c34bdeed8915bfb53bee0c8ed2254dbd8ec69c0014b7f3702f347c049bf358", size = 5425737, upload-time = "2026-09-10T15:33:51.473Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/de2c0c20a42c18e565a2617932beb08c06697bbdd0d3f62b108262e11583/fonttools-4.65.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:05595385ae99f4b9626cebb973bf171b8fe38a8f40708e6e42abba0ed7537778", size = 5402463, upload-time = "2026-09-10T15:33:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/2e/bc0f5c9dce21821454bb5812d3b23410bca33c8bbd5468386d0327aa0cff/fonttools-4.65.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d95b34dd68fbfc0e4a1740c421597656117f979ed8dc85de66e08f9f9981806e", size = 5362168, upload-time = "2026-09-10T15:33:57.481Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0d/2116763ade7e71e0e5d421babe1785d745be9b3d605bf914792ce1c97f79/fonttools-4.65.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:924d06e6130429168318db71c40174a765ad016fc4b56ca811287e3d7373b3a6", size = 5524117, upload-time = "2026-09-10T15:34:00.021Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5e/f9600553b9f645e3068553831685ff1dab6259536a23b38d2e048de38f17/fonttools-4.65.0-cp312-cp312-win32.whl", hash = "sha256:04f73dd01005752a6e75cf4a8dc6b70dc724d1d4bc34cc89522153f4a2f07680", size = 2432089, upload-time = "2026-09-10T15:34:02.803Z" }, + { url = "https://files.pythonhosted.org/packages/3a/02/e436a6a1863b9862bab9f82d6da33055dd7aa3738017edd902a163525dc2/fonttools-4.65.0-cp312-cp312-win_amd64.whl", hash = "sha256:3b5d9ba89edf778b376e669b879ae33a198bf45cf5a23c3f6514f935cf9d0d9d", size = 2483475, upload-time = "2026-09-10T15:34:05.09Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/f894ceb867118c0261d0f69a9bd516b045a3754238f76c88a49513ac7a83/fonttools-4.65.0-py3-none-any.whl", hash = "sha256:3060b8c1fc2329fa20265b7c138614143ea7c1624e26c5c180c76aeb74deae6f", size = 1196441, upload-time = "2026-09-10T15:35:52.347Z" }, ] [[package]] name = "idna" -version = "3.18" +version = "3.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] @@ -409,29 +409,28 @@ wheels = [ [[package]] name = "kiwisolver" -version = "1.5.0" +version = "1.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/bd78e6a8fae171ea041ef5bba3ed21a003522fa088834b069b1909981f30/kiwisolver-1.5.1.tar.gz", hash = "sha256:f1303ef2eec81262a4b708c3e858afe58d7c75ad91c1c05266eda7673369859a", size = 104395, upload-time = "2026-08-28T10:28:27.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, - { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, - { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, - { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, - { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, - { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, - { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, - { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, - { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, - { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, - { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, - { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, - { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, - { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, - { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9b/65b302742389c6f96f2956bef5decf26011309feb2fc5d79613af18adea4/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63fb7294b768f444eb4b068965f2662f28c2fd4161e23bd60fcf3ff27b74c046", size = 123876, upload-time = "2026-08-28T10:25:27.44Z" }, + { url = "https://files.pythonhosted.org/packages/71/74/c21f339956f6f691b2ed7e31d5f3ae767304df6c460192739fc830853051/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ebdef3eae5336568147c39a55be6a2036ffde53faa9ca2d978989ae7c2da12c", size = 66487, upload-time = "2026-08-28T10:25:28.728Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/bdb34e21523e01dceda064d63713f3bdec91388af24fba1eca7ea5e85864/kiwisolver-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1798e83840c3f627246104c4d8a9639c60fa068adf9ce92b61791781fa8a68c1", size = 64660, upload-time = "2026-08-28T10:25:30.071Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f4/dadfec469313c7f428efa7e84b4aba9732f813c13ea7131a24b7b008ef57/kiwisolver-1.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34633ecf50d16187ab8e5528b7a2530f2feb4e23f300db4672538b51cfc5cd38", size = 1477929, upload-time = "2026-08-28T10:25:31.495Z" }, + { url = "https://files.pythonhosted.org/packages/6f/35/09c58daac34e6f6ea5c6dee0094b422118e5a7c265586008a95fd135ac5f/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27c2123977cb9269c30a49ba45f03a4323017ef693e19db4ec9dbe1299a3002", size = 1278499, upload-time = "2026-08-28T10:25:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/19/32/739765e24fbad29d13f83e546ea4abc215a78cea9d677ca09025b027724d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a797a1cefc8b9c93170db580337e1fe3d011ad18b1299943231279406342048", size = 1296677, upload-time = "2026-08-28T10:25:35.059Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/03304d1010e2cc45e5b3b52cef7e43fed3a2a5cd6c87a89b4a88e1d85b5d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2551cf9917af48ee7c4b29cc82320489508cf96fd26a51f6fc124de661cd44c7", size = 1346037, upload-time = "2026-08-28T10:25:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/3e/57/4c49377bfd274450dd72ecaa13eaac32ea804a03363e4d1db0c5aa999ceb/kiwisolver-1.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:38f6e0deb4d0a4615efe0c4efc5990b06ae450ab50a0b321c0b078b6d238c083", size = 988248, upload-time = "2026-08-28T10:25:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/38df144a08b6c5d75ca4504e5cc3141bb3bfef64c04f4ef48204f42711b6/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bfd1de989b3330420e29de39352f5c049905c9e3ee67233a50d550e3d652c148", size = 2228722, upload-time = "2026-08-28T10:25:40.038Z" }, + { url = "https://files.pythonhosted.org/packages/e7/11/3221838a89cd64d9b386353e000cd8a296069a20fbe3584507fdfd5bebae/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1209042a623ddfda5497e4066c7b77651dde8e1d3a9dd97599dc7e97f3b9b78c", size = 2325216, upload-time = "2026-08-28T10:25:41.699Z" }, + { url = "https://files.pythonhosted.org/packages/83/d4/075c219230697bb5db910d37262b9bacf880f92b4811a02ab81ed073a253/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:26e8268480be5061d509e29669d59103c067a26377a56491630ece11762e3858", size = 1977689, upload-time = "2026-08-28T10:25:43.559Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/1d219c3c2dd960983d0d4da623d916e9de6385df2b0bab3d1af0e9b8fccc/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d79308fa689fac89cbcfbd4dbfc80b5f95c54c5a7fd4d194be221f9d33d026e6", size = 2491443, upload-time = "2026-08-28T10:25:45.242Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d3/024208ec1079d273f1047468d1bdffbf38bb75b7b268090fd3a0301b9d9a/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b03af77d77e50edba2030fd5f7c352ff209314b09030a3cba7c14edf9a09a444", size = 2295200, upload-time = "2026-08-28T10:25:46.984Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/7b210498f9f92e1cd7855f260fa69ef056881087b199ee20c208f0e4189a/kiwisolver-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:06a6917674de9e0fe3f66f5430787f59a9f2ddb64af9b714eaec547e29ef5c19", size = 70748, upload-time = "2026-08-28T10:25:48.444Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/ef0daa157c8bb23672f7423e0d14c39db1dc6ef8ed47e6bc54c9c1bef3bf/kiwisolver-1.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:ad8b9671348d7c8716715652ae11f85ed0eb99e265a2df2ca490577d69860b2c", size = 68324, upload-time = "2026-08-28T10:25:49.81Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c4/1407df7512a5b36cc79840e01710dc575733c461b13ab866cae77eaf87f3/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:482676e5bd48d70ac99d9fc78863469845421e01184fa83f1f9366dc49f7e974", size = 134002, upload-time = "2026-08-28T10:28:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/16/45/c37a21ad5c0ab581a93c55ad544721aaa1f0ae94edb29c6a678a23d013e6/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:072bdb15a3c19a5b5dbc8f8fb1f4e1884bf4f3507eeb4cc6334401274d37a5c0", size = 194292, upload-time = "2026-08-28T10:28:11.06Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/69f00d627949580e43d57af0aa465df46868d7c29801c137a55374101294/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:a5a00665d1a0e26763a7338d7e911d4598fbc1d50dd0d6b7919b7dc6c5d6569f", size = 73362, upload-time = "2026-08-28T10:28:12.449Z" }, ] [[package]] @@ -478,7 +477,7 @@ wheels = [ [[package]] name = "matplotlib" -version = "3.11.1" +version = "3.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy" }, @@ -491,15 +490,15 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4", size = 32660610, upload-time = "2026-09-11T19:05:31.214Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/6c/7ef7ebcb2bd9739b2b66b18b076e077f44bb46fdbe28ca0506edb3c62c79/matplotlib-3.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e15ef41507f3d525f46154ac9e3ae785dacde9f20e593a25de8986267892ef74", size = 9453849, upload-time = "2026-07-18T03:38:19.593Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/6d0c312c8d9738e7d9677f09fe5c986b3239e651a7b73a2deb38b65e4a71/matplotlib-3.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21a67b961a6d597bca54fae826cd20695ba4a6e4d05424a08da6e13e3176fd6b", size = 9283113, upload-time = "2026-07-18T03:38:21.95Z" }, - { url = "https://files.pythonhosted.org/packages/c9/cf/b4ad2cc81b6672ea29ea04e64e350a9f9b493b0908ccd884c67eeff8f7b2/matplotlib-3.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba8f811b8ddfac493734d6af0b2dff96919d0c28ca0d641858dab4262777c6ea", size = 10035615, upload-time = "2026-07-18T03:38:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/88/90/4e10e033d9b66589d8ed98b84c95cdbb57033d57c1f41339d7393dbd2f2e/matplotlib-3.11.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c52f7ad20ef476806ed212380b1d54d20310c8b86bdc2c9a68b51f0024a44472", size = 10842559, upload-time = "2026-07-18T03:38:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/88/eb/799612d0f8cd3e816a10fec59329fca52cd2353264df80378dfc541ae855/matplotlib-3.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b14eb22961fe865efb0e4ff167e333e428908b00115a8d800ccb65ee108e481", size = 10927532, upload-time = "2026-07-18T03:38:28.532Z" }, - { url = "https://files.pythonhosted.org/packages/88/89/56649bbaa2fd12e20f3be03dbcc135b0c8676d88bac17977599e3eb442a0/matplotlib-3.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:88a2a27dd9691ae448dfae4b26f59036be90c3c28757edd3553a29559d00859f", size = 9333886, upload-time = "2026-07-18T03:38:30.477Z" }, - { url = "https://files.pythonhosted.org/packages/c1/11/4d124efbbad677b7b7552f6f85a3bd432d4232f95400cea98fcd2ae36ef3/matplotlib-3.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:480194afceca4df2f137c2721227d3cba67121fbf4397b69cee7f83714b0a58a", size = 9007545, upload-time = "2026-07-18T03:38:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ce/1bfcc4873b121597791ad74032943b123218c0613af0b97e8dd05e916fb1/matplotlib-3.11.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef752769cd962f39ea0b6ffc82d1ea43a0012c5a6157c7a075212fa509cfcff2", size = 9476466, upload-time = "2026-09-11T19:03:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c4/7f5f3601ee69baf072c0c7d3ce60c03e0618621c5a56c34a62a460e29d11/matplotlib-3.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef31985c4dedb5f1424e1aec6849a47dd37689cb7fa3c20b1b82187f26806261", size = 9305583, upload-time = "2026-09-11T19:03:22.39Z" }, + { url = "https://files.pythonhosted.org/packages/b8/90/2b3fd67ee273163faeda6d514be70b5596eaa0fd77b60ffc294ad0b34f5f/matplotlib-3.11.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df4f7784aca81a94f254c0a2767d592ee25f407e488f5fa7203e51093fb6ca27", size = 9860353, upload-time = "2026-09-11T19:03:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/f4/84/32549e7a462dc311aed2ab62e5d2538028840b5c53a8be0195c789937c3d/matplotlib-3.11.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b9a7ad579856284135e401ecc918c5f8a017ee30539298862a109f51b971710", size = 10670348, upload-time = "2026-09-11T19:03:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/84/39/02e21b74f7439bd643d717ea846006d46e309e6d29b632b57751265311d6/matplotlib-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3aa4b8516fd26659e4363abbf317c703d9116496c5db2e9d0609a2866dd39dd2", size = 10810580, upload-time = "2026-09-11T19:03:31.402Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/0d239bde12b308262265c2d98909b2f0ecad2b5deeae96241642e822e9c7/matplotlib-3.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:c5c1c68ee401fc98271263410f0e5ce88285abacf7627132914e8adf3d70ff43", size = 9349409, upload-time = "2026-09-11T19:03:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/49/a8/06baf901c02246c8a222b655cc4540ef9c15e1549a04e07927f9cde716c3/matplotlib-3.11.2-cp312-cp312-win_arm64.whl", hash = "sha256:643ff850d8e0f5b8319337f87ed3cb59506afb3df3cc48de777d85871233be7b", size = 9028084, upload-time = "2026-09-11T19:03:37.032Z" }, ] [[package]] @@ -532,21 +531,21 @@ provides-extras = ["dev"] [[package]] name = "numpy" -version = "2.5.2" +version = "2.5.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, - { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, - { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, - { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, - { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, - { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, - { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, - { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, ] [[package]] @@ -796,11 +795,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, ] [package.optional-dependencies] @@ -831,23 +830,24 @@ wheels = [ [[package]] name = "pyzmq" -version = "27.1.0" +version = "27.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "implementation_name == 'pypy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/8d/5b3d5631c2f4b4b8862f64cd0c9eb777b5710eeb5125b4be8dd0a200a4c0/pyzmq-27.2.0.tar.gz", hash = "sha256:54d4259d1bfae24ecdb5ca79f7acc2eac6c286a02d6a0ae617797cb45f0726d3", size = 292316, upload-time = "2026-08-20T19:08:21.19Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, - { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/153532fa53db30e116118164f3af269a1f3966b3e2ba32c89b12fe864bd8/pyzmq-27.2.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:591c8de5851c5ea372194469fe97587b97c3b641e9a70f31bb3474acbfde0241", size = 1431074, upload-time = "2026-08-20T19:06:40.601Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ef/c08b91248bb90a9efa81fa00ba81b69c157c74d0c5efbb2c319d91babb62/pyzmq-27.2.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:00e73942ef12cecbc7951c4a9104bb8ffaed742abb13af2da6833d90dd368cef", size = 973915, upload-time = "2026-08-20T19:06:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/b4/78/a3a3a86c2b00fadb92ece1ca4f8f028d62b2ce9ac3526097239ab2d6fba9/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8079d0521fe94bbb401fe9407578b28f3701627c8be2c9f7e0c5b77dcb0109", size = 697722, upload-time = "2026-08-20T19:06:43.325Z" }, + { url = "https://files.pythonhosted.org/packages/62/2c/d5828306f795e8d34676d266823b74e2101e0ad3760d12083de3e02abbb2/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dea74fd65f1fc5f7fe167916a473ebe6ed6174e5e5d9de11ea6583661be6cf43", size = 872258, upload-time = "2026-08-20T19:06:44.627Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/51253b78fd8739293e283407eeecb14215c02c71b6519af21f6eed8e69cd/pyzmq-27.2.0-cp312-abi3-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcc99ca132b667a4ed750afd42db4ea73288f18425a9b2e3c0af095665c491f5", size = 739591, upload-time = "2026-08-20T19:06:46.214Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3e/142c85b67a4c9678629b0cf6d5125b29663d75be69bfaa57a3cac344d780/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b8d5f66e4a8246cf77f7b8f7902af64f00553368fa0373c89d99b78f0ad79394", size = 1689031, upload-time = "2026-08-20T19:06:47.612Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ee/0776fb0f98ed1eb74d77240087fef0ab045b6ad15cb09555c6c5134c98ad/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:d1526b42a2e725b84ed226f37becedc250c6347594e5ed304e4e9aff68c9aec3", size = 2059547, upload-time = "2026-08-20T19:06:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/ec77f691a4aebe29ab6329f996fb0e0270c876a3016086e3ca6ef733bcae/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f707bcf2c1d007d14d70531d4dd7b41060881c73efa845580bf6faaf9ea24d42", size = 1910457, upload-time = "2026-08-20T19:06:50.783Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/1f5530ff4fc271b4597048371d5af972c2baab51be132ba15874e0327a6a/pyzmq-27.2.0-cp312-abi3-win32.whl", hash = "sha256:fdaaa4ea3242f6ad298eb5177eb042aea5c73c30e76d20caee7b15af20d24ec2", size = 563450, upload-time = "2026-08-20T19:06:52.307Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl", hash = "sha256:2c218c6ab8bc447ba62054b581fd30209689d199c6ecb253f79615ca74a38e12", size = 628633, upload-time = "2026-08-20T19:06:53.809Z" }, + { url = "https://files.pythonhosted.org/packages/52/aa/3918b5ac7f9987bd9c421b065074fd7409ded88f856f2c704a24341877ec/pyzmq-27.2.0-cp312-abi3-win_arm64.whl", hash = "sha256:348d6fd3e4b81ae4580622ea8c2ea60224e84b2ac1b3be4482e6edc7de06e7a3", size = 556006, upload-time = "2026-08-20T19:06:55.242Z" }, ] [[package]] @@ -896,27 +896,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.3" +version = "0.16.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, - { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, - { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, - { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, - { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, - { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, - { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, - { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, - { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, ] [[package]] @@ -966,18 +966,18 @@ wheels = [ [[package]] name = "sounddevice" -version = "0.5.5" +version = "0.5.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/db/0c890e2d9aab9ba284021efc02e1d3aebfecab1b611762d7434602209bcf/sounddevice-0.5.6.tar.gz", hash = "sha256:8ec9fbfde2e32f020b167e348f3ab3bac6625a5f15af524d790108ac7147a410", size = 1120094, upload-time = "2026-08-17T07:55:05.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" }, - { url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" }, - { url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" }, - { url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" }, - { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, + { url = "https://files.pythonhosted.org/packages/72/1f/62eef605172bddc1017508469a12f75bc7c4194ece35c734f822795f53b1/sounddevice-0.5.6-py3-none-any.whl", hash = "sha256:de099612311ad81e55d31ccbd83f43ea6bf4d87b48f9b6ea55a1fbcde0eee4e0", size = 32793, upload-time = "2026-08-17T07:54:57.507Z" }, + { url = "https://files.pythonhosted.org/packages/b6/84/85e719d49cf98b2f406d9ac9c338892286c4448eb42ef0b2625ccf159616/sounddevice-0.5.6-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:e3aef00ad8b1d1740eb66d9a7671eab88a4d2b8fa4ab33498d742e63b65c309c", size = 1009647, upload-time = "2026-08-17T07:54:58.814Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6f/6292145099f72a153a710245f46ae43e5fb6c77bec1b6086cb76c12dc280/sounddevice-0.5.6-py3-none-win32.whl", hash = "sha256:b36b807eb02abd257198bf84b2af05e4fea199a9d2f0019014169c7136d45e9c", size = 1009627, upload-time = "2026-08-17T07:55:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3e/cbc593c31a5f0d817b3fe97e64aa8461bd0f55cb07b67ce1b776296ae336/sounddevice-0.5.6-py3-none-win_amd64.whl", hash = "sha256:7f4162f514f007b0bf25a3ccfed3f1705bc2ec311888a90232729eec4f57a4f4", size = 1009630, upload-time = "2026-08-17T07:55:02.088Z" }, + { url = "https://files.pythonhosted.org/packages/60/a4/b0c21c9f215a6fd9606b8f8748c21212dc098e5d5a2d93068c50edcf19b4/sounddevice-0.5.6-py3-none-win_arm64.whl", hash = "sha256:c8ae19173e5f27f8c12d4b5eee2dbfe542cee125d591e663e0fb4dfb75246d45", size = 1009630, upload-time = "2026-08-17T07:55:03.689Z" }, ] [[package]] @@ -1079,39 +1079,39 @@ provides-extras = ["linting", "testing-minimal", "testing-unit", "testing", "doc [[package]] name = "tqdm" -version = "4.70.0" +version = "4.70.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, + { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" }, ] [[package]] name = "ty" -version = "0.0.72" +version = "0.0.80" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/df/656e684bafb13c1d146e7d5b5f3e7978ca177232acc84998ff36427e9462/ty-0.0.72.tar.gz", hash = "sha256:ec2b8066b618df18cab4cb8e992f8da45d360332acb23fa34df7fa29cd1b9d3a", size = 6654939, upload-time = "2026-08-14T21:35:42.612Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/b0/6d1b10e0d422736a3c439e487c950ae785f401d71ff879d5be68bccb6d90/ty-0.0.80.tar.gz", hash = "sha256:fe86bc91327e45ff5e3593b7e306e7f57a44bc0608f38d647b8d97bd99c96013", size = 7183998, upload-time = "2026-09-09T21:18:47.547Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/3b/f51461239a4e66565d4b362f97a3b55fe7fdba2e944068341f87c62f6743/ty-0.0.72-py3-none-linux_armv6l.whl", hash = "sha256:fda86db153ffd85ee52000cf175d6a3f1c0223772cf7c5b6f726200bf92c7b44", size = 12621989, upload-time = "2026-08-14T21:35:01.676Z" }, - { url = "https://files.pythonhosted.org/packages/ca/fb/79ddf683affc679ca856f3510b5640ec3a88a842ba5f654f5d4bc78f1786/ty-0.0.72-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ceb944c612529b9023acfdc9cf4c0dcbb722549f9d17d46baecd1141baf01d7f", size = 12233910, upload-time = "2026-08-14T21:35:04.334Z" }, - { url = "https://files.pythonhosted.org/packages/5d/45/10562a0d84802158db8fa4ec46de54aa9fdcecdeeaabbfe3639ae7042b66/ty-0.0.72-py3-none-macosx_11_0_arm64.whl", hash = "sha256:108d76218333d6c092e5f1cebf8e9b06f25738613a0236a28e2dd47c936ee52c", size = 12084108, upload-time = "2026-08-14T21:35:06.686Z" }, - { url = "https://files.pythonhosted.org/packages/a1/dc/1fe1aef8d697e3509face271a5331700c7aa1d1e44a4b622707bdfa41d4b/ty-0.0.72-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f3943f186f741a2499a31053872169250c9264a9a49684920e48d8fcf4ef4f5", size = 12132640, upload-time = "2026-08-14T21:35:09.305Z" }, - { url = "https://files.pythonhosted.org/packages/14/46/41ceb265e96969487311a2014bd0e53abb4fbc1395efb2ebe411fcb4db62/ty-0.0.72-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf283c07dc3cc52ca48a3ad8ab100fb5aec3aebbd03ef6a12d5f910b8e596fc5", size = 12402489, upload-time = "2026-08-14T21:35:11.555Z" }, - { url = "https://files.pythonhosted.org/packages/2b/45/30bf43cb4fd505c5c2dd30fda27dde5f05208686cd21217adec77c954204/ty-0.0.72-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95f3b6462c38f9f115d10cee21f47fedf715fcf2040daf36eef210359300bc7c", size = 13130835, upload-time = "2026-08-14T21:35:13.746Z" }, - { url = "https://files.pythonhosted.org/packages/31/2f/03bba754d2613f640df168335c41f83f41db150bb515839c60d80e3a7880/ty-0.0.72-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30caf658feb8ffb250d9e9e47107657a78f5f3425c227df1664d8df2ebe38880", size = 13590392, upload-time = "2026-08-14T21:35:16.839Z" }, - { url = "https://files.pythonhosted.org/packages/04/c7/03c67f00e63005ec41585653dc3096064570b1e6273742baae2798cd242f/ty-0.0.72-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bdc012ddfbeec8948e4a6036c0dc39ac7cf2c8ec7c7d48dc7d2fd56d57b399", size = 13309629, upload-time = "2026-08-14T21:35:19.169Z" }, - { url = "https://files.pythonhosted.org/packages/c1/df/102d3b264eb7f2a58dd11952f229bb5150bb5668d176a6154976a6675981/ty-0.0.72-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:802c5970a77d7739e6f499921fbb6984fb7ad8a31d95e1ff42fd46f3642e4f3b", size = 12734028, upload-time = "2026-08-14T21:35:22.099Z" }, - { url = "https://files.pythonhosted.org/packages/61/85/d0737c8c54d0ba67366ddfb9f31d88edf0b02299e65923e6945ae60ebcb5/ty-0.0.72-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:47dce65114fdc615c68ca0edb393b433df0956447e4267df0e264137a789598d", size = 13174832, upload-time = "2026-08-14T21:35:24.71Z" }, - { url = "https://files.pythonhosted.org/packages/1e/31/497f5a96c36d9b586ab6afe0574986835c6fd5b835a89773d2bec4711b49/ty-0.0.72-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:325144fa07e2675d0faa337fcc864213c272a499eb0cfe5bde2fdc62282d27bc", size = 12215005, upload-time = "2026-08-14T21:35:26.892Z" }, - { url = "https://files.pythonhosted.org/packages/df/7d/46e65b17b4966c7cd0140f134380d33d8e84fe6efccd761533ce793dc502/ty-0.0.72-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5c9f15d0f58e43707d8848274be1821a0ef408eccb8aa7dda28a4a9eddf7640", size = 12421298, upload-time = "2026-08-14T21:35:29.301Z" }, - { url = "https://files.pythonhosted.org/packages/08/2a/12ada4ec17700b3cb1d4fd3bc3e5b1852df9e6885288429318cade87b3c1/ty-0.0.72-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee508d64b381871529cc22c412b41071bf5e908b7aa5d66a38f3f6b2573a806", size = 12669242, upload-time = "2026-08-14T21:35:31.444Z" }, - { url = "https://files.pythonhosted.org/packages/1c/1a/4692536880790fb550ed6d44a6096778dc71bb112f2c6d615cebb01a57e5/ty-0.0.72-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3699e2ec7921d44da79d6b089f7bf239b2cc53c4e45a5a38430adc34ee9e9a55", size = 12988199, upload-time = "2026-08-14T21:35:33.749Z" }, - { url = "https://files.pythonhosted.org/packages/9a/0d/f5e5a50322e9c45865e7b7a428ba6cd6527387cf0f2472492ac3cf746243/ty-0.0.72-py3-none-win32.whl", hash = "sha256:f25f72a67bd36cd247707c4784e52fad0b6b4f42a1b7dd14804110fa95c486ed", size = 11939708, upload-time = "2026-08-14T21:35:36.006Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4e/8af3534b2e4214e6184a5a59c34101e94a68d578f081f97b995866bab1bf/ty-0.0.72-py3-none-win_amd64.whl", hash = "sha256:cdeee869341717e1736cea2e2d7856738c6957c320f584ed2f68c8f90100d2f5", size = 12643876, upload-time = "2026-08-14T21:35:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/a2606e654c7276bd08586391a2525b0af3f3bf60228a8c57b2d248f273f9/ty-0.0.72-py3-none-win_arm64.whl", hash = "sha256:1bd3ac3ed4424a6d6990a85dc388556aea012bd752de21349a84b685951de0d8", size = 12394857, upload-time = "2026-08-14T21:35:40.277Z" }, + { url = "https://files.pythonhosted.org/packages/29/c8/3c93195eca282936ebb574d0ba98843e4b147ee324006f88464777012a92/ty-0.0.80-py3-none-linux_armv6l.whl", hash = "sha256:738d1cfca466c577aea24547c348629c00c63548cf7da2c46b497b3840f85dee", size = 13606509, upload-time = "2026-09-09T21:18:10.476Z" }, + { url = "https://files.pythonhosted.org/packages/e7/48/bbb47f7001c97262a5109bcd92a45bcc8a2fbb21e994c214a9897630bfb7/ty-0.0.80-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:56060164bb8ee43770fa367524fbdba2611fc90f8923a02cc91f80eb37d5a1b0", size = 13203812, upload-time = "2026-09-09T21:18:12.796Z" }, + { url = "https://files.pythonhosted.org/packages/db/8d/fd141160567047b742e466be8ed09a4c4ed80fa045d37f946eb4f4532fee/ty-0.0.80-py3-none-macosx_11_0_arm64.whl", hash = "sha256:da4062e0fbf3923d9b71688157c5753394f243348f00732e9edbb27498397169", size = 13021896, upload-time = "2026-09-09T21:18:15.186Z" }, + { url = "https://files.pythonhosted.org/packages/1e/10/b4177faf9e71bc37bc4d08f512042269660a1ce0ec457c48f96e51fc91d7/ty-0.0.80-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9eb94b2659f506a3a07a9ea1415d5c18aaa1e554adc1612614d617a0ed320e1", size = 13083881, upload-time = "2026-09-09T21:18:17.616Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c2/192d48b9a9acbbe030fc9a4bd73af75671a5566e731949f77e912a68ce68/ty-0.0.80-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2f5e39a87da48c1af1a3b3135f02be7a83a93da30b0d6a5f5d27806e7c747ce", size = 13354874, upload-time = "2026-09-09T21:18:19.744Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/736dfd31efd98bed9dbe018ff91676e5ea83485b1e24afb635fd0247728f/ty-0.0.80-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e00149e7779c6b98f3ba12e10a631a861bd7ca1a42bbf064e2677ada7d2c0c2", size = 14204805, upload-time = "2026-09-09T21:18:21.999Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6f/557c726cb53401998e3589bb632c963a6887a70240e0a4386024f3731c81/ty-0.0.80-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a377268f359fb6e7a2cc9026a09223c764f58d020dec8e6baeaba9caba6ff829", size = 14630014, upload-time = "2026-09-09T21:18:24Z" }, + { url = "https://files.pythonhosted.org/packages/e9/19/7a4b18fe27f6b6bd4ffa56b67c8b09ecb76bb4312d1c64b73f65417b4dbd/ty-0.0.80-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4511dbf1b266b9ce5b73e9b28d94468096c2b6da00e2fe205168c32d3b352ca7", size = 14326946, upload-time = "2026-09-09T21:18:26.679Z" }, + { url = "https://files.pythonhosted.org/packages/f8/95/16dd90805fc7e53ec54ae9fdb1a8b74753fb159a55a170f60f49b9417824/ty-0.0.80-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe95feffa7156800c6f804195acb9fb5846a39671c7192c30fff23351aaf7c31", size = 13705988, upload-time = "2026-09-09T21:18:28.681Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/4e87992c23ab8a742c7d4914ac5fe66fb9301c8464a69fd2ecbc0be3fbcd/ty-0.0.80-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ba26b39f06bc8c3c2c5acd3a147239482b36620cdbca1b9d02e915294854f2cc", size = 14233729, upload-time = "2026-09-09T21:18:30.961Z" }, + { url = "https://files.pythonhosted.org/packages/36/e9/b8c11fda8e66a1d1cc1a7160ed719a33f4dc0107b1cef6a14e2673965763/ty-0.0.80-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2e6167320888c115a6fbe69893b63fd1c848f9121a454e75e5f612674528e3f3", size = 13173523, upload-time = "2026-09-09T21:18:33.266Z" }, + { url = "https://files.pythonhosted.org/packages/73/a7/512083fa540c5be1ac8642658f03bfb5bfa6fd168ea4f55e3c0aabe04166/ty-0.0.80-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7aecb62b4de70b479eab07d1779ea66da26dd8b068f50a9330867157312f103c", size = 13373014, upload-time = "2026-09-09T21:18:35.339Z" }, + { url = "https://files.pythonhosted.org/packages/5e/63/cd2f0ca81fd9b8cafef93bbcf022bf636bc4de8ee7465c6aafeec1d4d52b/ty-0.0.80-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4fed06adacd16b7e2d722f37449021b1419598d0440769701c0f4827faddde63", size = 13667827, upload-time = "2026-09-09T21:18:37.241Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5d/ebdfdcb2dc099ae74dc4b75511eef0dd398b2fa27006c543543974b4567f/ty-0.0.80-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:09329af6303ce611ec2cfffc995dc1ddf76c9f47194fc1a4d64a984759c25f5b", size = 13963866, upload-time = "2026-09-09T21:18:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/c286d5e1b560cfd16d6e91fdab683718aacf09fc88938ca06dcc9d1c8502/ty-0.0.80-py3-none-win32.whl", hash = "sha256:a81b3b512f7b4c68e42fbd1835633de658809666e9aafd5defa52bbc5197698d", size = 12881806, upload-time = "2026-09-09T21:18:41.507Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c8/3b3a8ac16d47a23ff34bf57c0d99a491860748aea96e0e886b4bfed54f9b/ty-0.0.80-py3-none-win_amd64.whl", hash = "sha256:8043f99878a2ae434781cb881c8a3840dff70c4a5b60bdc5ae84251f35ee063f", size = 13528883, upload-time = "2026-09-09T21:18:43.687Z" }, + { url = "https://files.pythonhosted.org/packages/2e/71/a6c697930fca76596d7d17f8853120f41a298fd9ea632c901cfca8ca869b/ty-0.0.80-py3-none-win_arm64.whl", hash = "sha256:e277ef034331da5319efc839c064968d24f35715b71c70369cf97d36fe4dcbdb", size = 13370135, upload-time = "2026-09-09T21:18:45.758Z" }, ] [[package]] @@ -1125,11 +1125,11 @@ wheels = [ [[package]] name = "websocket-client" -version = "1.9.0" +version = "1.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" }, ] [[package]] From 39146cb240975b6cf51ece8ee0a3faee787d773f Mon Sep 17 00:00:00 2001 From: Harsh Singh <143034341+singhharsh1708@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:00:51 +0530 Subject: [PATCH 079/270] test_runner: collect parameterized_class tests instead of skipping them with their base (#38820) --- tools/test_runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/test_runner.py b/tools/test_runner.py index 1979ce01e4..2121907e33 100755 --- a/tools/test_runner.py +++ b/tools/test_runner.py @@ -170,7 +170,7 @@ def collect(targets, keyword): cls = type(test) if cls.__name__ == "_FailedTest": continue - if getattr(cls, "__unittest_skip_why__", "") == "parameterized base class": + if cls.__dict__.get("__unittest_skip_why__", "") == "parameterized base class": continue if not keyword or keyword.lower() in test.id().lower(): tests.append(test) From f2357840427987b4bbe7abb9a019331f4aa8e382 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sat, 12 Sep 2026 17:26:45 -0700 Subject: [PATCH 080/270] Revert tinygrad update (#38879) --- tinygrad_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinygrad_repo b/tinygrad_repo index 76059714bf..f6fc4e3f2c 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit 76059714bf4468363b52358434f7ce35be7f425f +Subproject commit f6fc4e3f2c3db5fae1e19cbfbc3ad9fc579a12ae From f147094cb7c81e2b6d685d776b6a4df2cbffaeb4 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:44:43 -0700 Subject: [PATCH 081/270] cabana: restore Qt palettes and contrast (#38875) * cabana: restore Qt dark palette and contrast * cabana: restore Qt standard light palette and selection text * cabana: drive component colors from shared style values * cabana: remove historical palette comments --- .../tools/cabana/ui/chart/signalselector.cc | 8 +-- openpilot/tools/cabana/ui/chart/sparkline.cc | 7 +-- .../tools/cabana/ui/dialogs/filedialog.cc | 2 +- .../tools/cabana/ui/dialogs/routesdialog.cc | 2 +- openpilot/tools/cabana/ui/theme.cc | 61 ++++++++++--------- openpilot/tools/cabana/ui/theme.h | 13 ++-- openpilot/tools/cabana/ui/util.cc | 16 ++++- openpilot/tools/cabana/ui/util.h | 4 +- .../tools/cabana/ui/widgets/binaryview.cc | 16 ++--- .../tools/cabana/ui/widgets/detailwidget.cc | 4 +- .../tools/cabana/ui/widgets/messagebytes.cc | 5 +- .../tools/cabana/ui/widgets/signalview.cc | 10 +-- 12 files changed, 81 insertions(+), 67 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc index dd84c49cba..245603d035 100644 --- a/openpilot/tools/cabana/ui/chart/signalselector.cc +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -54,7 +54,7 @@ bool SignalSelector::draw() { inputText("##msgs_filter", &msgs_combo_filter_, "Select a message..."); for (int i = 0; i < (int)msgs_combo_.size(); ++i) { if (!msgs_combo_filter_.empty() && !utils::containsCI(msgs_combo_[i].text, msgs_combo_filter_)) continue; - if (ImGui::Selectable(msgs_combo_[i].text.c_str(), i == msgs_combo_index_)) { + if (selectable(msgs_combo_[i].text.c_str(), i == msgs_combo_index_)) { msgs_combo_index_ = i; updateAvailableList(i); ImGui::CloseCurrentPopup(); @@ -107,7 +107,7 @@ void SignalSelector::drawList(const char *id, std::vector &list, int * const auto &item = list[i]; ImGui::PushID(i); const ImVec2 pos = ImGui::GetCursorScreenPos(); - if (ImGui::Selectable("##item", i == *current_row)) *current_row = i; + if (selectable("##item", i == *current_row)) *current_row = i; if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { *current_row = i; *double_clicked = true; @@ -117,10 +117,10 @@ void SignalSelector::drawList(const char *id, std::vector &list, int * float x = pos.x + 5; drawColorMarker(dl, ImVec2(x, pos.y), toImU32(item.sig->color)); x += markerSize() + 4; - dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(ImGuiCol_Text), item.sig->name.c_str()); + dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(i == *current_row ? palette().text_selected : palette().text), item.sig->name.c_str()); if (show_msg_name) { x += ImGui::CalcTextSize(item.sig->name.c_str()).x; - dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(ImGuiCol_TextDisabled), msgLabel(item.msg_id).c_str()); + dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(i == *current_row ? palette().text_selected : palette().text_disabled), msgLabel(item.msg_id).c_str()); } ImGui::PopID(); } diff --git a/openpilot/tools/cabana/ui/chart/sparkline.cc b/openpilot/tools/cabana/ui/chart/sparkline.cc index cf0482c197..1e804f2765 100644 --- a/openpilot/tools/cabana/ui/chart/sparkline.cc +++ b/openpilot/tools/cabana/ui/chart/sparkline.cc @@ -100,12 +100,7 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi } size = sz; - CabanaColor line_color = color; - if (!isDarkTheme()) { - auto [h, s, v] = color.hsv(); - line_color = CabanaColor::fromHsv(h, std::min(1.0f, s * 2.0f), v * 0.7f, color.a / 255.0f); - } - color_ = toImU32(line_color); + color_ = toImU32(sparklineColor(color)); draw_individual_points_ = draw_individual_points; window_end_ = window_end; xscale_ = xscale; diff --git a/openpilot/tools/cabana/ui/dialogs/filedialog.cc b/openpilot/tools/cabana/ui/dialogs/filedialog.cc index 5721d8edbc..7ac420e499 100644 --- a/openpilot/tools/cabana/ui/dialogs/filedialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/filedialog.cc @@ -165,7 +165,7 @@ void draw() { const std::string label = (is_dir ? std::string(icon::FOLDER) : std::string(icon::FILE_EARMARK)) + " " + name; ImGui::PushID(static_cast(i)); const bool selected = !is_dir && name == s.filename; - if (ImGui::Selectable(label.c_str(), selected, ImGuiSelectableFlags_AllowDoubleClick)) { + if (selectable(label.c_str(), selected, ImGuiSelectableFlags_AllowDoubleClick)) { const bool double_clicked = ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left); if (is_dir) { if (double_clicked) { diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc index 8e192dce17..9e21464985 100644 --- a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -108,7 +108,7 @@ void RoutesDialog::draw() { } for (int i = 0; i < static_cast(s_.routes.size()); ++i) { ImGui::PushID(i); - if (ImGui::Selectable(s_.routes[i].label.c_str(), s_.route_index == i, ImGuiSelectableFlags_AllowDoubleClick)) { + if (selectable(s_.routes[i].label.c_str(), s_.route_index == i, ImGuiSelectableFlags_AllowDoubleClick)) { s_.route_index = i; if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) accepted = true; } diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc index ff02ca24c7..9a2a6741bc 100644 --- a/openpilot/tools/cabana/ui/theme.cc +++ b/openpilot/tools/cabana/ui/theme.cc @@ -12,30 +12,35 @@ namespace fs = std::filesystem; namespace { constexpr Palette DARK_PALETTE = { - .text = rgb(0xf8f9f9), .text_disabled = rgb(0xb8c0c4), - .window = rgb(0x1d2225), .surface = rgb(0x30373b), - .frame = rgb(0x1e2224), .frame_hovered = rgb(0x394044), .frame_active = rgb(0x424a4f), - .button = rgb(0x424a4f), .button_hovered = rgb(0x535f64), .button_active = rgb(0x175886), - .header = rgb(0x175886), .header_hovered = rgb(0x24455e), .header_active = rgb(0x1c6ea8), - .accent = rgb(0x57a9e3), - .border = rgb(0x65737a), .separator = rgb(0x4b5559), - .tab = rgb(0x272c2f), .tab_hovered = rgb(0x424a4f), .table_header = rgb(0x424a4f), - .grid = rgb(0x65737a, 0.45f), .badge = rgb(0x808080), + .text = rgb(0xbbbbbb), .text_disabled = rgb(0x777777), .text_selected = rgb(0xbbbbbb), + .window = rgb(0x353535), .surface = rgb(0x3c3f41), + .frame = rgb(0x3c3f41), .frame_hovered = rgb(0x484b4d), .frame_active = rgb(0x505355), + .button = rgb(0x484b4d), .button_hovered = rgb(0x535658), .button_active = rgb(0x3c3f41), + .header = rgb(0x2f65ca), .header_hovered = rgb(0x414e65), .header_active = rgb(0x2f65ca), + .accent = rgb(0x2f65ca), + .border = rgb(0x282828), .separator = rgb(0x353535), .scrollbar_grab = rgb(0x484b4d), + .tab = rgb(0x353535), .tab_hovered = rgb(0x484b4d), .table_header = rgb(0x484b4d), + .grid = rgb(0xbbbbbb, 50.0f / 255.0f), .badge = rgb(0x808080), + .bit_background = rgb(0xffffff, 20.0f / 255.0f), + .heatmap_signal_alpha = 70.0, .heatmap_bit_alpha = 28.0, .heatmap_gamma = 0.6, + .sparkline_saturation = 1.0f, .sparkline_value = 1.0f, }; constexpr Palette LIGHT_PALETTE = { - .text = rgb(0x1e2224), .text_disabled = rgb(0x535f64), - .window = rgb(0xeeeff0), .surface = rgb(0xffffff), - .frame = rgb(0xf8f9f9), .frame_hovered = rgb(0xeeeff0), .frame_active = rgb(0xddeef9), - .button = rgb(0xe3e6e8), .button_hovered = rgb(0xd8dcdf), .button_active = rgb(0xbcddf4), - .header = rgb(0xbcddf4), .header_hovered = rgb(0xddeef9), .header_active = rgb(0x9fcbec), - .accent = rgb(0x1c6ea8), - .border = rgb(0x98a3a9), .separator = rgb(0xcdd3d6), - .tab = rgb(0xe3e6e8), .tab_hovered = rgb(0xddeef9), .table_header = rgb(0xd8dcdf), - .grid = rgb(0x98a3a9, 0.4f), .badge = rgb(0xa0a0a4), + .text = rgb(0x000000), .text_disabled = rgb(0xbebebe), .text_selected = rgb(0xffffff), + .window = rgb(0xefefef), .surface = rgb(0xffffff), + .frame = rgb(0xffffff), .frame_hovered = rgb(0xf5f9fc), .frame_active = rgb(0xe7f3fb), + .button = rgb(0xefefef), .button_hovered = rgb(0xe7f3fb), .button_active = rgb(0xd4e7f4), + .header = rgb(0x308cc6), .header_hovered = rgb(0xe7f3fb), .header_active = rgb(0x308cc6), + .accent = rgb(0x308cc6), + .border = rgb(0xb6b6b6), .separator = rgb(0xd0d0d0), .scrollbar_grab = rgb(0xb6b6b6), + .tab = rgb(0xe5e5e5), .tab_hovered = rgb(0xefefef), .table_header = rgb(0xefefef), + .grid = rgb(0x000000, 50.0f / 255.0f), .badge = rgb(0xa0a0a4), + .bit_background = rgb(0xffffff, 0.0f), + .heatmap_signal_alpha = 25.0, .heatmap_bit_alpha = 10.0, .heatmap_gamma = 1.0, + .sparkline_saturation = 2.0f, .sparkline_value = 0.7f, }; -bool g_dark = false; const Palette *g_palette = &LIGHT_PALETTE; ImFont *g_ui_font = nullptr; ImFont *g_bold_font = nullptr; @@ -82,8 +87,7 @@ void loadFonts() { } void applyTheme(int theme) { - g_dark = theme == DARK_THEME; - g_palette = g_dark ? &DARK_PALETTE : &LIGHT_PALETTE; + g_palette = theme == DARK_THEME ? &DARK_PALETTE : &LIGHT_PALETTE; const Palette &p = *g_palette; const ImVec4 none(0, 0, 0, 0); @@ -110,7 +114,7 @@ void applyTheme(int theme) { c[ImGuiCol_Text] = p.text; c[ImGuiCol_TextDisabled] = p.text_disabled; c[ImGuiCol_WindowBg] = c[ImGuiCol_ScrollbarBg] = c[ImGuiCol_DockingEmptyBg] = p.window; - c[ImGuiCol_MenuBarBg] = p.surface; + c[ImGuiCol_MenuBarBg] = p.window; c[ImGuiCol_TitleBg] = c[ImGuiCol_TitleBgActive] = c[ImGuiCol_TitleBgCollapsed] = p.window; c[ImGuiCol_ChildBg] = c[ImGuiCol_PopupBg] = p.surface; c[ImGuiCol_Border] = c[ImGuiCol_TableBorderStrong] = p.border; @@ -121,7 +125,7 @@ void applyTheme(int theme) { c[ImGuiCol_FrameBgActive] = p.frame_active; c[ImGuiCol_Button] = p.button; c[ImGuiCol_ButtonHovered] = c[ImGuiCol_SliderGrab] = p.button_hovered; - c[ImGuiCol_ScrollbarGrab] = p.border; + c[ImGuiCol_ScrollbarGrab] = p.scrollbar_grab; c[ImGuiCol_ScrollbarGrabHovered] = p.text_disabled; c[ImGuiCol_ButtonActive] = p.button_active; c[ImGuiCol_Header] = p.header; @@ -138,7 +142,7 @@ void applyTheme(int theme) { c[ImGuiCol_TabSelected] = c[ImGuiCol_TabDimmedSelected] = p.surface; c[ImGuiCol_TabDimmedSelectedOverline] = none; c[ImGuiCol_TableHeaderBg] = p.table_header; - c[ImGuiCol_TableRowBgAlt] = g_dark ? ImVec4(1, 1, 1, 0.065f) : ImVec4(0, 0, 0, 0.045f); + c[ImGuiCol_TableRowBgAlt] = none; c[ImGuiCol_PlotLines] = p.text; // ImGuiStyle() seeds every slot from the dark theme: set the rest so the light theme does not keep a white caret. c[ImGuiCol_InputTextCursor] = c[ImGuiCol_UnsavedMarker] = p.text; @@ -152,13 +156,12 @@ void applyTheme(int theme) { ImPlot::GetStyle().Colors[ImPlotCol_AxisGrid] = p.grid; } -bool isDarkTheme() { return g_dark; } const Palette &palette() { return *g_palette; } -CabanaColor signalFillColor(const CabanaColor &c) { - if (!g_dark) return c; - auto [h, s, v] = c.hsv(); - return CabanaColor::fromHsv(h, std::min(1.0f, s * 1.4f), v * 0.8f, c.a / 255.0f); +CabanaColor sparklineColor(const CabanaColor &color) { + const Palette &p = palette(); + auto [h, s, v] = color.hsv(); + return CabanaColor::fromHsv(h, std::min(1.0f, s * p.sparkline_saturation), v * p.sparkline_value, color.a / 255.0f); } ImFont *boldFont() { return g_bold_font; } diff --git a/openpilot/tools/cabana/ui/theme.h b/openpilot/tools/cabana/ui/theme.h index 91f2a0740f..dbd0d97814 100644 --- a/openpilot/tools/cabana/ui/theme.h +++ b/openpilot/tools/cabana/ui/theme.h @@ -5,20 +5,21 @@ #include "tools/cabana/core/color.h" -// Palette source: commaai/connect src/{colors,theme}.js at 7091050. -// Dark colors follow connect; light colors use its lightGrey and lightBlue families. struct Palette { - ImVec4 text, text_disabled; + ImVec4 text, text_disabled, text_selected; ImVec4 window; // the background behind panels and docked windows ImVec4 surface; // panels, popups, table bodies: what content is drawn on ImVec4 frame, frame_hovered, frame_active; ImVec4 button, button_hovered, button_active; ImVec4 header, header_hovered, header_active; // selections ImVec4 accent; - ImVec4 border, separator; + ImVec4 border, separator, scrollbar_grab; ImVec4 tab, tab_hovered, table_header; ImVec4 grid; ImVec4 badge; // the fill behind the time labels drawn over a chart + ImVec4 bit_background; // overlay beneath heatmap bits without a signal + double heatmap_signal_alpha, heatmap_bit_alpha, heatmap_gamma; + float sparkline_saturation, sparkline_value; // HSV multipliers for signal colors }; constexpr ImVec4 rgb(unsigned hex, float alpha = 1.0f) { @@ -35,10 +36,8 @@ constexpr float UI_FONT_SIZE = 16.0f; void loadFonts(); void applyTheme(int theme); // Safe to call at runtime. -bool isDarkTheme(); const Palette &palette(); - -CabanaColor signalFillColor(const CabanaColor &c); +CabanaColor sparklineColor(const CabanaColor &color); ImFont *boldFont(); void pushMonoFont(float size = 0.0f); diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 4566064a27..50553c2d47 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -89,13 +89,23 @@ bool clearableInput(const char *label, std::string *s, const char *hint, ImGuiIn return changed; } +bool selectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size) { + if (selected) { + ImGui::PushStyleColor(ImGuiCol_Text, palette().text_selected); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, palette().header); + } + const bool clicked = ImGui::Selectable(label, selected, flags, size); + if (selected) ImGui::PopStyleColor(2); + return clicked; +} + bool comboBox(const char *label, int *index, const std::vector &items) { bool changed = false; const int count = (int)items.size(); if (ImGui::BeginCombo(label, *index >= 0 && *index < count ? items[*index].c_str() : "")) { for (int i = 0; i < count; ++i) { ImGui::PushID(i); - if (ImGui::Selectable(items[i].c_str(), i == *index) && *index != i) { + if (selectable(items[i].c_str(), i == *index) && *index != i) { *index = i; changed = true; } @@ -241,7 +251,7 @@ void disabledItemTooltip(const char *text) { bool radioMenuItem(const char *label, bool checked, float width) { const float indent = ImGui::GetFontSize(); const ImVec2 pos = ImGui::GetCursorScreenPos(); - const bool clicked = ImGui::Selectable((std::string("##") + label).c_str(), false, ImGuiSelectableFlags_None, + const bool clicked = selectable((std::string("##") + label).c_str(), false, ImGuiSelectableFlags_None, ImVec2(ImMax(width, ImGui::GetContentRegionAvail().x), 0.0f)); const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); ImDrawList *painter = ImGui::GetWindowDrawList(); @@ -328,7 +338,7 @@ int tableHeadersRow() { bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size) { ImGui::PushStyleColor(ImGuiCol_HeaderHovered, selected ? ImGui::GetColorU32(ImGuiCol_Header) : IM_COL32(0, 0, 0, 0)); ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImGui::GetColorU32(ImGuiCol_Header)); - const bool clicked = ImGui::Selectable(label, selected, flags, size); + const bool clicked = selectable(label, selected, flags, size); ImGui::PopStyleColor(2); return clicked; } diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index d7a27fba7b..e1f368e1ac 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -38,6 +38,8 @@ bool beginControlChild(const char *id, const ImVec2 &size, ImGuiWindowFlags flag // SetNextItemWidth includes the field and clear button. Returns true when text changes. bool clearableInput(const char *label, std::string *s, const char *hint = "", ImGuiInputTextCallback validator = nullptr); +bool selectable(const char *label, bool selected, ImGuiSelectableFlags flags = 0, const ImVec2 &size = ImVec2(0, 0)); + bool comboBox(const char *label, int *index, const std::vector &items); // numeric items (bus ids, bus speeds) are formatted as they are drawn @@ -48,7 +50,7 @@ inline bool comboBox(const char *label, int *index, const T *values, int count) if (ImGui::BeginCombo(label, preview.c_str())) { for (int i = 0; i < count; ++i) { ImGui::PushID(i); - if (ImGui::Selectable(std::to_string(values[i]).c_str(), i == *index) && *index != i) { + if (selectable(std::to_string(values[i]).c_str(), i == *index) && *index != i) { *index = i; changed = true; } diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index 4469273607..84f5a29fd8 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -367,11 +367,11 @@ void BinaryView::updateState() { } } - const bool dark = isDarkTheme(); + const Palette &p = palette(); const double max_alpha = 255.0; - const double min_alpha_with_signal = dark ? 70.0 : 25.0; // Base alpha for small flip counts - const double min_alpha_no_signal = dark ? 28.0 : 10.0; // Base alpha for small flip counts for no signal bits - const double alpha_gamma = dark ? 0.6 : 1.0; + const double min_alpha_with_signal = p.heatmap_signal_alpha; // Base alpha for small flip counts + const double min_alpha_no_signal = p.heatmap_bit_alpha; // Base alpha for small flip counts for no signal bits + const double alpha_gamma = p.heatmap_gamma; const double log_factor = 1.0 + 0.2; const double log_scaler = max_alpha / log2(log_factor * max_bit_flip_count); @@ -456,13 +456,13 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary if (item->sigs.size() > 0) { for (auto &s : item->sigs) { if (s == hovered_sig_) { - painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalFillColor(s->color).darker(125))); // 4/5x brightness + painter->AddRectFilled(rect.Min, rect.Max, toImU32(s->color.darker(125))); // 4/5x brightness } else { drawSignalCell(painter, rect, index, s); } } } else if (item->valid) { - if (isDarkTheme()) painter->AddRectFilled(rect.Min, rect.Max, IM_COL32(255, 255, 255, 20)); + painter->AddRectFilled(rect.Min, rect.Max, ImGui::GetColorU32(palette().bit_background)); if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); } bool bright = std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); @@ -526,9 +526,9 @@ void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const B if (bottom_notch) band(bottom_notch, rc.Max.y - spacing, rc.Max.y); auto item = &cellAt(index); - CabanaColor color = signalFillColor(sig->color); + CabanaColor color = sig->color; color.a = item->bg_color.alpha(); - const ImU32 edge = toImU32(signalFillColor(sig->color).darker(125)); + const ImU32 edge = toImU32(sig->color.darker(125)); for (const ImRect &clip : region) { painter->PushClipRect(clip.Min, clip.Max, true); diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 0e5cda1bd2..cd3d3378a8 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -262,13 +262,15 @@ void DetailWidget::drawTabWidget() { const bool selected = tab_widget_index_ == i; ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetColorU32(selected ? ImGuiCol_Header : ImGuiCol_Button, selected ? 1.0f : 0.0f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::GetColorU32(selected ? ImGuiCol_HeaderActive : ImGuiCol_ButtonHovered)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, selected ? palette().header_active : palette().button_active); + ImGui::PushStyleColor(ImGuiCol_Text, selected ? palette().text_selected : palette().text); if (i) ImGui::SameLine(); if (ImGui::Button(labels[i].c_str()) && !selected) { tab_widget_index_ = i; if (i == 1) history_log_->onShown(); updateState(); } - ImGui::PopStyleColor(2); + ImGui::PopStyleColor(4); } ImGui::PopStyleVar(2); ImGui::EndChild(); diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.cc b/openpilot/tools/cabana/ui/widgets/messagebytes.cc index 97261039ee..63e31d6f83 100644 --- a/openpilot/tools/cabana/ui/widgets/messagebytes.cc +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.cc @@ -24,7 +24,10 @@ ImVec2 bytesCellSize(int n, bool multiple_lines) { } ImU32 cellTextColor(bool selected, bool inactive) { - if (selected && inactive) return withAlpha(ImGui::GetColorU32(ImGuiCol_Text), 100); + if (selected) { + const ImU32 text = ImGui::GetColorU32(palette().text_selected); + return inactive ? withAlpha(text, 100) : text; + } return ImGui::GetColorU32(inactive ? ImGuiCol_TextDisabled : ImGuiCol_Text); } diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index d98058354a..ef18357184 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -260,13 +260,13 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const ImRect rect(option_rect.Min.x + h_margin, option_rect.Min.y + v_margin, option_rect.Max.x - h_margin, option_rect.Max.y - v_margin); // selection background is painted by the row's Selectable - const ImU32 text_color = ImGui::GetColorU32(ImGuiCol_Text); + const ImU32 text_color = ImGui::GetColorU32(selected ? palette().text_selected : palette().text); if (column == 0) { if (item->type == SignalModel::Item::Sig) { // color label ImRect icon_rect(rect.Min.x, rect.Min.y, rect.Min.x + COLOR_LABEL_WIDTH, rect.Max.y); - painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(signalFillColor(item->sig->color).darker(item->highlight ? 125 : 0)), ImGui::GetStyle().FrameRounding); + painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(item->sig->color.darker(item->highlight ? 125 : 0)), ImGui::GetStyle().FrameRounding); drawText(painter, icon_rect, std::to_string(item->row() + 1).c_str(), item->highlight ? IM_COL32_WHITE : IM_COL32_BLACK, nullptr, LABEL_FONT); @@ -688,7 +688,7 @@ float SignalView::minimumWidth() { } void SignalView::draw() { - ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::GetStyleColorVec4(ImGuiCol_WindowBg)); + ImGui::PushStyleColor(ImGuiCol_ChildBg, palette().surface); if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_Borders)) { ImGui::EndChild(); ImGui::PopStyleColor(); @@ -823,7 +823,7 @@ bool SignalView::drawItem(SignalModel::Item *item, int depth, DrawContext &ctx) if (!item->children.empty()) { const float arrow_size = ImGui::GetFontSize() * 0.7f; ImGui::RenderArrow(ctx.draw_list, ImVec2(row_min.x + depth * INDENTATION + 4.0f, row_min.y + (row_height - arrow_size) * 0.5f), - ImGui::GetColorU32(ImGuiCol_Text), item->expanded ? ImGuiDir_Down : ImGuiDir_Right, 0.7f); + ImGui::GetColorU32(selected ? palette().text_selected : palette().text), item->expanded ? ImGuiDir_Down : ImGuiDir_Right, 0.7f); } // every row is measured, the header sizes column 0 to the contents of the whole tree @@ -936,7 +936,7 @@ bool ValueDescriptionDlg::draw() { if (row == current_row_) ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::GetColorU32(ImGuiCol_Header)); ImGui::TableSetColumnIndex(0); ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(std::to_string(row + 1).c_str()); + ImGui::TextColored(row == current_row_ ? palette().text_selected : palette().text, "%d", row + 1); ImGui::TableSetColumnIndex(1); ImGui::SetNextItemWidth(-FLT_MIN); if (valueDescriptionEditor(0, &table_[row].first)) current_row_ = row; From 7e84a6166832f1297217dd494247d30b30525862 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:45:14 -0700 Subject: [PATCH 082/270] cabana: use ImGui decorations for floating panes (#38871) * cabana: redraw floating panes during native resizing * cabana: keep rendering while native resize is held * cabana: use ImGui decorations to avoid native resize loops * cabana: draw docking previews only on the target viewport --- openpilot/tools/cabana/ui/app.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpilot/tools/cabana/ui/app.cc b/openpilot/tools/cabana/ui/app.cc index 3f4dac1016..e45eaac6fb 100644 --- a/openpilot/tools/cabana/ui/app.cc +++ b/openpilot/tools/cabana/ui/app.cc @@ -165,7 +165,8 @@ public: ImGuiIO &io = ImGui::GetIO(); io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable; - io.ConfigViewportsNoDecoration = false; + io.ConfigViewportsNoDecoration = true; + io.ConfigDockingTransparentPayload = true; io.IniFilename = nullptr; io.LogFilename = nullptr; if (!ImGui_ImplGlfw_InitForOpenGL(window, true)) { From 298e51010ce7902d0f84711c915fc249bd555412 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Sat, 12 Sep 2026 19:48:31 -0700 Subject: [PATCH 083/270] print jenkins crash log (#38880) --- Jenkinsfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Jenkinsfile b/Jenkinsfile index a5dfa86fb8..67039d0927 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -22,6 +22,7 @@ trap 'kill 0' HUP # stop this process group on SSH disconnect export CI=1 export PYTHONWARNINGS=error +export PYTHONFAULTHANDLER=1 export COMMA_CACHE=/data/tmp/comma_download_cache #export LOGPRINT=debug # this has gotten too spammy... export TEST_DIR=${env.TEST_DIR} From dc338c2831b8066412e5dd0ed452b2ff12b81846 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:37:01 -0700 Subject: [PATCH 084/270] cabana: standardize button spacing (#38873) * cabana: standardize button spacing and control alignment * cabana: center shared step controls and inset chart content * cabana: use uniform per-chart content insets --- openpilot/tools/cabana/ui/chart/chart.cc | 48 +++++----- openpilot/tools/cabana/ui/chart/chart.h | 1 + .../tools/cabana/ui/chart/chartswidget.cc | 25 +++--- .../tools/cabana/ui/chart/signalselector.cc | 2 + .../tools/cabana/ui/dialogs/filedialog.cc | 6 +- .../tools/cabana/ui/dialogs/settingsdialog.cc | 20 +---- .../tools/cabana/ui/dialogs/streamselector.cc | 11 +-- openpilot/tools/cabana/ui/mainwin.cc | 4 +- openpilot/tools/cabana/ui/theme.cc | 7 +- openpilot/tools/cabana/ui/theme.h | 8 ++ openpilot/tools/cabana/ui/tools/findsignal.cc | 33 ++++--- .../tools/cabana/ui/tools/findsimilarbits.cc | 20 +++-- openpilot/tools/cabana/ui/util.cc | 87 ++++++++++++++----- openpilot/tools/cabana/ui/util.h | 12 ++- .../tools/cabana/ui/widgets/detailwidget.cc | 9 +- .../cabana/ui/widgets/scrollabletabbar.cc | 4 +- .../tools/cabana/ui/widgets/signalview.cc | 21 +++-- .../tools/cabana/ui/widgets/signalview.h | 1 + .../tools/cabana/ui/widgets/videowidget.cc | 32 +++---- 19 files changed, 205 insertions(+), 146 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 8dc5d627d0..41256845c1 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -15,12 +15,9 @@ #include "tools/cabana/ui/util.h" #include "tools/cabana/utils/strings.h" -const int AXIS_X_TOP_MARGIN = 4; const int X_TICK_COUNT = 5; const double MIN_ZOOM_SECONDS = 0.01; // 10ms const double EPSILON = 1e-6; -constexpr ImVec4 LAYOUT_MARGINS{0, 6, 0, 6}; // left, top, right, bottom -constexpr int LEGEND_SPACING = 5; static inline bool xLessThan(const ImPlotPoint &p, double x) { return p.x < (x - EPSILON); } static inline bool isNull(const ImPlotPoint &p) { return p.x == 0 && p.y == 0; } @@ -135,27 +132,32 @@ void ChartView::manageSignals() { void ChartView::updateLayout() { const ImVec2 grip = ImGui::CalcTextSize(icon::GRIP_HORIZONTAL); - const ImVec2 top_left = layout_.rect.Min + ImVec2(LAYOUT_MARGINS.x, LAYOUT_MARGINS.y); + const ImGuiStyle &style = ImGui::GetStyle(); + // WindowPadding can be zero in a borderless pane or drag preview. Chart + // content always uses the shared control gap, independently of its parent. + layout_.content_rect = layout_.rect; + layout_.content_rect.Expand(-style.ItemSpacing.x); + const ImVec2 top_left = layout_.content_rect.Min; layout_.move_icon_rect = ImRect(top_left, top_left + grip); const ImVec2 btn_size(iconButtonWidth(), iconButtonWidth()); - const ImVec2 close_min(layout_.rect.Max.x - ImGui::GetStyle().WindowPadding.x - btn_size.x, top_left.y); + const ImVec2 close_min(layout_.content_rect.Max.x - btn_size.x, top_left.y); layout_.close_btn_rect = ImRect(close_min, close_min + btn_size); - const ImVec2 manage_min(close_min.x - btn_size.x - ImGui::GetStyle().ItemInnerSpacing.x, top_left.y); + const ImVec2 manage_min(close_min.x - btn_size.x - ImGui::GetStyle().ItemSpacing.x, top_left.y); layout_.manage_btn_rect = ImRect(manage_min, manage_min + btn_size); ImFont *bold = boldFont(); const float font_size = ImGui::GetFontSize(); const float fm_height = ImGui::GetTextLineHeight(); const int marker_size = markerSize(); - const int row_height = std::max(marker_size, fm_height) + fm_height + 3; // + the signal value line - const int legend_left = layout_.move_icon_rect.Max.x + LEGEND_SPACING; - const int legend_right = std::max(layout_.manage_btn_rect.Min.x - ImGui::GetStyle().ItemInnerSpacing.x, legend_left + 10); + const int row_height = std::max(marker_size, fm_height) + fm_height + style.ItemInnerSpacing.y; // + the signal value line + const int legend_left = layout_.move_icon_rect.Max.x + style.ItemSpacing.x; + const int legend_right = std::max(layout_.manage_btn_rect.Min.x - ImGui::GetStyle().ItemSpacing.x, legend_left + 10); // layout legend entries left-to-right, wrapping between the move icon and the buttons layout_.legend_rects.clear(); int x = legend_left, y = top_left.y; for (auto &s : sigs_) { - int w = marker_size + LEGEND_SPACING + bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x + + int w = marker_size + style.ItemInnerSpacing.x + bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x + ImGui::CalcTextSize(msgLabel(s.msg_id).c_str()).x; pushMonoFont(font_size); w = std::max(w, (int)std::ceil(ImGui::CalcTextSize("-0.00000e+000").x)); @@ -166,13 +168,11 @@ void ChartView::updateLayout() { y += row_height; } layout_.legend_rects.emplace_back(ImVec2(x, y), ImVec2(x + w, y + std::max(marker_size, fm_height))); - x += w + 12; + x += w + style.ItemSpacing.x; } // add top space for the legend and signal values - int adjust_top = (y + row_height) - top_left.y; - adjust_top = std::max(adjust_top, layout_.manage_btn_rect.Max.y - layout_.rect.Min.y + LAYOUT_MARGINS.y); - layout_.header_bottom = layout_.rect.Min.y + adjust_top + LAYOUT_MARGINS.y; + layout_.header_bottom = std::max(y + row_height, layout_.manage_btn_rect.Max.y) + ImGui::GetStyle().ItemSpacing.y; } void ChartView::updatePlot(double cur, double min, double max) { @@ -554,9 +554,9 @@ void ChartView::drawStaticLayer() { } void ChartView::drawAxes() { - ImGui::SetCursorScreenPos(ImVec2(layout_.rect.Min.x, layout_.header_bottom)); - const float plot_h = std::max(layout_.rect.Max.y - layout_.header_bottom - LAYOUT_MARGINS.w, 10.0f); - ImPlot::PushStyleVar(ImPlotStyleVar_PlotPadding, ImVec2(LAYOUT_MARGINS.x, AXIS_X_TOP_MARGIN)); + ImGui::SetCursorScreenPos(ImVec2(layout_.content_rect.Min.x, layout_.header_bottom)); + const float plot_h = std::max(layout_.content_rect.Max.y - layout_.header_bottom, 10.0f); + ImPlot::PushStyleVar(ImPlotStyleVar_PlotPadding, ImVec2(0.0f, ImGui::GetStyle().ItemInnerSpacing.y)); ImPlot::PushStyleColor(ImPlotCol_PlotBg, ImVec4(0, 0, 0, 0)); ImPlot::PushStyleColor(ImPlotCol_FrameBg, ImVec4(0, 0, 0, 0)); ImPlot::PushStyleColor(ImPlotCol_PlotBorder, palette().grid); @@ -568,8 +568,8 @@ void ChartView::drawAxes() { ImPlotFlags_NoBoxSelect | ImPlotFlags_NoInputs | ImPlotFlags_NoFrame; const ImPlotAxisFlags axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight | ImPlotAxisFlags_NoSideSwitch | ImPlotAxisFlags_Lock; // reserve room for the right half of the last x tick label - const float x_label_width = ImGui::CalcTextSize(formatNumber(x_max_, xAxisPrecision()).c_str()).x + 5; - if (ImPlot::BeginPlot("##plot", ImVec2(layout_.rect.GetWidth() - x_label_width / 2, plot_h), flags)) { + const float x_label_width = ImGui::CalcTextSize(formatNumber(x_max_, xAxisPrecision()).c_str()).x + ImGui::GetStyle().ItemInnerSpacing.x; + if (ImPlot::BeginPlot("##plot", ImVec2(layout_.content_rect.GetWidth() - x_label_width / 2, plot_h), flags)) { ImPlot::SetupAxis(ImAxis_X1, nullptr, axis_flags); ImPlot::SetupAxis(ImAxis_Y1, y_unit_.empty() ? nullptr : y_unit_.c_str(), axis_flags); ImPlot::SetupAxisLimits(ImAxis_X1, x_min_, x_max_, ImPlotCond_Always); @@ -624,7 +624,7 @@ void ChartView::drawLegend() { drawColorMarker(painter, r.Min, toImU32(s.color)); } - float x = r.Min.x + marker_size + LEGEND_SPACING; + float x = r.Min.x + marker_size + ImGui::GetStyle().ItemInnerSpacing.x; const float text_y = r.GetCenter().y - font_size / 2.0f; addTextEllipsis(painter, bold, title_color, ImVec2(x, text_y), r.Max.x, s.sig->name); float name_w = std::min(bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x, r.Max.x - x); @@ -633,7 +633,7 @@ void ChartView::drawLegend() { addTextEllipsis(painter, normal, msg_color, ImVec2(x, text_y), r.Max.x, msg); if (!s.visible) { // strike out const float y = r.GetCenter().y; - painter->AddLine(ImVec2(r.Min.x + marker_size + LEGEND_SPACING, y), ImVec2(std::min(x + ImGui::CalcTextSize(msg.c_str()).x, r.Max.x), y), title_color); + painter->AddLine(ImVec2(r.Min.x + marker_size + ImGui::GetStyle().ItemInnerSpacing.x, y), ImVec2(std::min(x + ImGui::CalcTextSize(msg.c_str()).x, r.Max.x), y), title_color); } } } @@ -730,10 +730,10 @@ void ChartView::drawRubberBandTimeRange() { painter->PushClipRect(layout_.rect.Min, layout_.rect.Max); for (const auto &pt : {rubber_rect_.GetBL(), rubber_rect_.GetBR()}) { std::string sec = formatNumber(secondsAtPoint(pt), 2); - ImVec2 size = ImGui::CalcTextSize(sec.c_str()) + ImVec2(12, AXIS_X_TOP_MARGIN * 2); + ImVec2 size = ImGui::CalcTextSize(sec.c_str()) + ImVec2(12, ImGui::GetStyle().ItemInnerSpacing.y * 2); ImVec2 top_left = pt.x == rubber_rect_.Min.x ? ImVec2(pt.x - size.x, pt.y + 2) : ImVec2(pt.x, pt.y + 2); painter->AddRectFilled(top_left, top_left + size, badge, ImGui::GetStyle().FrameRounding); - painter->AddText(top_left + ImVec2(6, AXIS_X_TOP_MARGIN), white, sec.c_str()); + painter->AddText(top_left + ImVec2(6, ImGui::GetStyle().ItemInnerSpacing.y), white, sec.c_str()); } painter->PopClipRect(); } @@ -745,7 +745,7 @@ void ChartView::drawTimeline() { std::string time_str = formatNumber(cur_sec_, 2); ImVec2 time_str_size = ImGui::CalcTextSize(time_str.c_str()) + ImVec2(8, 2); - ImVec2 time_str_pos(x - time_str_size.x / 2.0f, layout_.plot_area.Max.y + AXIS_X_TOP_MARGIN); + ImVec2 time_str_pos(x - time_str_size.x / 2.0f, layout_.plot_area.Max.y + ImGui::GetStyle().ItemInnerSpacing.y); painter->AddRectFilled(time_str_pos, time_str_pos + time_str_size, ImGui::GetColorU32(palette().badge), ImGui::GetStyle().FrameRounding); painter->AddText(time_str_pos + ImVec2(4, 1), IM_COL32_WHITE, time_str.c_str()); } diff --git a/openpilot/tools/cabana/ui/chart/chart.h b/openpilot/tools/cabana/ui/chart/chart.h index d32906390a..7e7152f6df 100644 --- a/openpilot/tools/cabana/ui/chart/chart.h +++ b/openpilot/tools/cabana/ui/chart/chart.h @@ -106,6 +106,7 @@ private: // layout struct Layout { ImRect rect; // the whole chart widget, screen coordinates + ImRect content_rect; // the same inset on all four sides, including during a drag ImRect plot_area; ImRect move_icon_rect; ImRect close_btn_rect; diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index 24ce32aa25..94a5fa81b2 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -15,7 +15,6 @@ #include "tools/cabana/utils/strings.h" const int MAX_COLUMN_COUNT = 4; -const int CHART_SPACING = 4; const int START_DRAG_DISTANCE = 10; const float MIN_RANGE_SLIDER_WIDTH = 40.0f; @@ -166,12 +165,11 @@ void ChartsWidget::drawToolBar() { // the labels are captured by reference, they outlive the draw calls below std::vector items; items.push_back({iconButtonWidth(), [this]() { - if (iconButton("new_plot_btn", icon::PLUS_LG, "New Chart")) newChart(); + if (stepButton("new_plot_btn", true, "New Chart")) newChart(); }}); items.push_back({iconButtonWidth(), [this]() { if (iconButton("new_tab_btn", icon::WINDOW_PLUS, "New Tab")) newTab(); }}); - items.back().tight = true; const std::string title_label = "Charts: " + std::to_string(charts_.size()); items.push_back({ImGui::CalcTextSize(title_label.c_str()).x, [&title_label]() { ImGui::AlignTextToFramePadding(); @@ -231,11 +229,11 @@ void ChartsWidget::drawToolBar() { pushMonoFont(ImGui::GetFontSize()); const float reset_zoom_width = iconTextButtonWidth(icon::ZOOM_OUT, widest + "-" + widest); popMonoFont(); - items.push_back({iconButtonWidth() * 2 + ImGui::GetStyle().ItemInnerSpacing.x, [this]() { + items.push_back({iconButtonWidth() * 2 + ImGui::GetStyle().ItemSpacing.x, [this]() { ImGui::BeginDisabled(!zoom_undo_stack_.canUndo()); if (iconButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); ImGui::EndDisabled(); - ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + ImGui::SameLine(); ImGui::BeginDisabled(!zoom_undo_stack_.canRedo()); if (iconButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); ImGui::EndDisabled(); @@ -247,12 +245,11 @@ void ChartsWidget::drawToolBar() { if (clicked) zoomReset(); ImGui::SetItemTooltip("Reset Zoom"); }}); - items.back().tight = true; } items.push_back(toolbarAction("remove_all_btn", icon::TRASH, "Remove all charts", [this]() { removeAll(); }, !charts_.empty())); const char *dock_btn_icon = is_docked_ ? icon::BOX_ARROW_UP_RIGHT : icon::BOX_ARROW_IN_DOWN_LEFT; const char *dock_label = is_docked_ ? "Float the charts window" : "Dock the charts window"; - items.push_back(toolbarAction("dock_btn", dock_btn_icon, dock_label, [this]() { toggleChartsDocking(); }, true, true)); + items.push_back(toolbarAction("dock_btn", dock_btn_icon, dock_label, [this]() { toggleChartsDocking(); })); // the slider shrinks first, the buttons stay pinned to the right edge if (slider_index != (size_t)-1) { @@ -356,7 +353,7 @@ void ChartsWidget::updateLayout() { int n = MAX_COLUMN_COUNT; for (; n > 1; --n) { - if ((n * CHART_MIN_WIDTH + (n - 1) * CHART_SPACING) < container_width) break; + if ((n * CHART_MIN_WIDTH + (n - 1) * ImGui::GetStyle().ItemSpacing.x) < container_width) break; } columns_action_visible_ = n > 1; @@ -625,14 +622,14 @@ void ChartsContainer::draw() { charts_widget_->updateLayout(); const int n = std::max(charts_widget_->current_column_count_, 1); - const float spacing = CHART_SPACING; + const float spacing = ImGui::GetStyle().ItemSpacing.x; const float width = (geometry_.GetWidth() - (n - 1) * spacing) / n; - const ImVec2 origin = ImGui::GetCursorScreenPos() + ImVec2(0, CHART_SPACING); + const ImVec2 origin = ImGui::GetCursorScreenPos(); auto current_charts = charts_widget_->currentCharts(); // copy: drawing may remove charts float bottom = origin.y; const bool aligned = ImPlot::BeginAlignedPlots("charts_align", true); for (int i = 0; i < current_charts.size(); ++i) { - ImVec2 pos = origin + ImVec2((i % n) * (width + spacing), (i / n) * (settings.chart_height + spacing)); + ImVec2 pos = origin + ImVec2((i % n) * (width + spacing), (i / n) * (settings.chart_height + ImGui::GetStyle().ItemSpacing.y)); ImGui::SetCursorScreenPos(pos); current_charts[i]->draw(width); bottom = std::max(bottom, pos.y + settings.chart_height); @@ -640,15 +637,15 @@ void ChartsContainer::draw() { } if (aligned) ImPlot::EndAlignedPlots(); ImGui::SetCursorScreenPos(ImVec2(origin.x, bottom)); - ImGui::Dummy(ImVec2(geometry_.GetWidth(), CHART_SPACING)); - geometry_.Max.y = bottom + CHART_SPACING; + ImGui::Dummy(ImVec2(geometry_.GetWidth(), ImGui::GetStyle().ItemSpacing.y)); + geometry_.Max.y = bottom + ImGui::GetStyle().ItemSpacing.y; drawDropIndicator(); } void ChartsContainer::drawDropIndicator() { if (!(drop_indicator_pos_.x == 0 && drop_indicator_pos_.y == 0) && !childAt(drop_indicator_pos_)) { ImRect r = geometry_; - r.Max.y = r.Min.y + CHART_SPACING; + r.Max.y = r.Min.y + ImGui::GetStyle().ItemSpacing.y; if (auto insert_after = getDropAfter(drop_indicator_pos_)) { float h = r.GetHeight(); r.Min.y = insert_after->rect().Max.y; diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc index 245603d035..1f29a99ac7 100644 --- a/openpilot/tools/cabana/ui/chart/signalselector.cc +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -41,6 +41,7 @@ bool SignalSelector::draw() { const float lists_h = ImGui::GetContentRegionAvail().y - ImGui::GetFrameHeightWithSpacing() * 3; ImGui::BeginGroup(); + ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Available Signals"); // a combo popup with a filter box const char *preview = msgs_combo_index_ >= 0 ? msgs_combo_[msgs_combo_index_].text.c_str() : "Select a message..."; @@ -79,6 +80,7 @@ bool SignalSelector::draw() { ImGui::SameLine(); ImGui::BeginGroup(); + ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Selected Signals"); bool remove_dbl = false; drawList("##selected_list", selected_list_, &selected_row_, true, &remove_dbl, ImVec2(column_w, lists_h + ImGui::GetFrameHeightWithSpacing())); diff --git a/openpilot/tools/cabana/ui/dialogs/filedialog.cc b/openpilot/tools/cabana/ui/dialogs/filedialog.cc index 7ac420e499..c03dcad205 100644 --- a/openpilot/tools/cabana/ui/dialogs/filedialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/filedialog.cc @@ -188,10 +188,12 @@ void draw() { if (!pending_dir.empty()) setDir(pending_dir); if (s.mode != Mode::Directory) { - ImGui::SetNextItemWidth(-90.0f); + const std::string filter = s.extension.empty() ? "*" : "*" + s.extension; + ImGui::SetNextItemWidth(-(ImGui::CalcTextSize(filter.c_str()).x + ImGui::GetStyle().ItemSpacing.x)); if (inputText("##name", &s.filename, "File name", ImGuiInputTextFlags_EnterReturnsTrue)) ok = true; ImGui::SameLine(); - ImGui::TextDisabled("%s", s.extension.empty() ? "*" : ("*" + s.extension).c_str()); + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("%s", filter.c_str()); } const char *accept_label = s.mode == Mode::SaveFile ? "Save" : (s.mode == Mode::Directory ? "Choose" : "Open"); dialogButtons(accept_label, &ok, &cancel); diff --git a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc index f67e2f2ad8..96f10cff1a 100644 --- a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc @@ -23,7 +23,7 @@ const char *FORM_LABELS[FORM_LABEL_COUNT] = {"Color Theme", "Max Cached Minutes" float formLabelWidth() { float w = 0.0f; for (const char *label : FORM_LABELS) w = std::max(w, ImGui::CalcTextSize(label).x); - return w + ImGui::GetStyle().ItemSpacing.x * 2; // horizontal spacing between label and field + return ImGui::GetCursorPosX() + w + ImGui::GetStyle().ItemSpacing.x; } void formRow(FormLabel label, float label_width) { @@ -34,22 +34,8 @@ void formRow(FormLabel label, float label_width) { } void settingInputInt(const char *id, int *value, int step, int step_fast, int minimum, int maximum) { - const float spacing = ImGui::GetStyle().ItemInnerSpacing.x; - const float width = ImGui::CalcItemWidth(); - ImGui::PushID(id); - ImGui::BeginGroup(); - ImGui::SetNextItemWidth(width - 2 * (iconButtonWidth() + spacing)); - ImGui::InputInt("##value", value, 0); + inputInt((std::string("##") + id).c_str(), value, step, step_fast); *value = std::clamp(*value, minimum, maximum); - const int increment = ImGui::GetIO().KeyCtrl ? step_fast : step; - ImGui::PushItemFlag(ImGuiItemFlags_ButtonRepeat, true); - ImGui::SameLine(0.0f, spacing); - if (iconButton("decrement", icon::DASH_LG)) *value = std::max(minimum, *value - increment); - ImGui::SameLine(0.0f, spacing); - if (iconButton("increment", icon::PLUS_LG)) *value = std::min(maximum, *value + increment); - ImGui::PopItemFlag(); - ImGui::EndGroup(); - ImGui::PopID(); } } // namespace @@ -89,7 +75,7 @@ void SettingsDialog::draw() { checkBox("Enable live stream logging", &log_livestream_); ImGui::BeginDisabled(!log_livestream_); - ImGui::SetNextItemWidth(-90.0f); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Browse...") + ImGui::GetStyle().ItemSpacing.x)); inputText("##log_path", &log_path_, "", ImGuiInputTextFlags_ReadOnly); ImGui::SameLine(); if (ImGui::Button("Browse...")) { diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.cc b/openpilot/tools/cabana/ui/dialogs/streamselector.cc index f1d2b1da13..17cae9635c 100644 --- a/openpilot/tools/cabana/ui/dialogs/streamselector.cc +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.cc @@ -18,7 +18,8 @@ void OpenReplayWidget::draw() { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Route"); ImGui::SameLine(); - ImGui::SetNextItemWidth(-250.0f); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Remote Route...") + toolbarButtonWidth("Local Route...") + + ImGui::GetStyle().ItemSpacing.x * 2)); inputText("##route", &route_, "Enter a route name or browse for a local or remote route"); ImGui::SameLine(); if (ImGui::Button("Remote Route...")) { @@ -134,7 +135,7 @@ void OpenPandaWidget::draw() { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Serial"); ImGui::SameLine(); - ImGui::SetNextItemWidth(-100.0f); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Refresh") + ImGui::GetStyle().ItemSpacing.x)); if (comboBox("##serial", &serial_index_, serials_)) buildConfigForm(); ImGui::SameLine(); if (ImGui::Button("Refresh")) { @@ -228,10 +229,10 @@ void OpenSocketCanWidget::draw() { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Device"); ImGui::SameLine(); - ImGui::SetNextItemWidth(300.0f); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Refresh") + ImGui::GetStyle().ItemSpacing.x)); if (comboBox("##device", &device_index_, devices_)) config.device = devices_[device_index_]; ImGui::SameLine(); - if (ImGui::Button("Refresh", ImVec2(100.0f, 0.0f))) refreshDevices(); + if (ImGui::Button("Refresh")) refreshDevices(); } std::unique_ptr OpenSocketCanWidget::open() { @@ -289,7 +290,7 @@ void StreamSelector::draw() { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("DBC File"); ImGui::SameLine(); - ImGui::SetNextItemWidth(-90.0f); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Browse...") + ImGui::GetStyle().ItemSpacing.x)); inputText("##dbc", &dbc_file_, "Choose a DBC file to open", ImGuiInputTextFlags_ReadOnly); ImGui::SameLine(); if (ImGui::Button("Browse...")) { diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 3f8c815e77..343494a03c 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -904,16 +904,18 @@ void MainWindow::drawVideoPanel() { } } // Replay uses a splitter for the gap; live streams use normal item spacing. - if (!charts_floating_ && !live) ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); if (video_h > 0.0f) { ImGui::BeginChild("video", ImVec2(0, video_h), ImGuiChildFlags_Borders); help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); video_widget_->draw(); ImGui::EndChild(); + // The splitter supplies the pane gap; keep normal spacing inside the video child. + if (!charts_floating_ && !live) ImGui::SetCursorPosY(ImGui::GetCursorPosY() - ImGui::GetStyle().ItemSpacing.y); } else { video_widget_->setVisible(false); // the splitter collapsed the video: stop the vipc thread } if (!charts_floating_ && !live) { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); ImGui::InvisibleButton("##splitter", ImVec2(-1.0f, splitter_h)); const bool splitter_hovered = ImGui::IsItemHovered() && !live, splitter_active = ImGui::IsItemActive() && !live; if (splitter_active) { diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc index 9a2a6741bc..696a933b82 100644 --- a/openpilot/tools/cabana/ui/theme.cc +++ b/openpilot/tools/cabana/ui/theme.cc @@ -103,9 +103,10 @@ void applyTheme(int theme) { style.WindowBorderSize = 1.0f; style.FrameBorderSize = 1.0f; style.TabBorderSize = 1.0f; - style.WindowPadding = ImVec2(12.0f, 10.0f); - style.FramePadding = ImVec2(9.0f, 5.0f); - style.ItemSpacing = ImVec2(10.0f, 8.0f); + style.WindowPadding = ImVec2(spacing::CONTROL, spacing::CONTROL); + style.FramePadding = ImVec2(spacing::CONTROL, spacing::INNER); + style.ItemSpacing = ImVec2(spacing::CONTROL, spacing::CONTROL); + style.ItemInnerSpacing = ImVec2(spacing::INNER, spacing::INNER); style.CellPadding = ImVec2(6.0f, 4.0f); style.ScrollbarSize = 14.0f; style.GrabMinSize = 13.0f; diff --git a/openpilot/tools/cabana/ui/theme.h b/openpilot/tools/cabana/ui/theme.h index dbd0d97814..020b137c62 100644 --- a/openpilot/tools/cabana/ui/theme.h +++ b/openpilot/tools/cabana/ui/theme.h @@ -32,6 +32,14 @@ inline ImU32 toImU32(const CabanaColor &c) { return IM_COL32(c.r, c.g, c.b, c.a) inline ImVec4 toImVec4(const CabanaColor &c) { return ImVec4(c.r / 255.0f, c.g / 255.0f, c.b / 255.0f, c.a / 255.0f); } inline ImU32 withAlpha(ImU32 c, int alpha) { return (c & ~IM_COL32_A_MASK) | ((ImU32)alpha << IM_COL32_A_SHIFT); } +// Logical pixels. External control gaps are equal on both axes; inner spacing +// is reserved for parts of one control (icon/label, checkbox/label, dropdown arrow). +namespace spacing { +constexpr float CONTROL = 8.0f; +constexpr float INNER = 4.0f; +constexpr float DIALOG_BUTTON_MIN_WIDTH = 80.0f; +} // namespace spacing + constexpr float UI_FONT_SIZE = 16.0f; void loadFonts(); diff --git a/openpilot/tools/cabana/ui/tools/findsignal.cc b/openpilot/tools/cabana/ui/tools/findsignal.cc index c3ce025f4c..734107aa9f 100644 --- a/openpilot/tools/cabana/ui/tools/findsignal.cc +++ b/openpilot/tools/cabana/ui/tools/findsignal.cc @@ -76,12 +76,15 @@ bool FindSignalDlg::draw() { } searching_ = search_future_.valid(); if (begin(ImVec2(900, 650))) { - float group_w = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) / 2; - ImGui::BeginChild("Messages", ImVec2(group_w, 0), ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY); + const ImGuiStyle &style = ImGui::GetStyle(); + const float group_w = (ImGui::GetContentRegionAvail().x - style.ItemSpacing.x) / 2; + const float group_h = ImGui::GetTextLineHeightWithSpacing() + ImGui::GetFrameHeightWithSpacing() * 4 + + style.WindowPadding.y * 2 - style.ItemSpacing.y; + ImGui::BeginChild("Messages", ImVec2(group_w, group_h), ImGuiChildFlags_Borders); drawMessageGroup(); ImGui::EndChild(); ImGui::SameLine(); - ImGui::BeginChild("Signal", ImVec2(group_w, 0), ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY); + ImGui::BeginChild("Signal", ImVec2(group_w, group_h), ImGuiChildFlags_Borders); drawPropertiesGroup(); ImGui::EndChild(); float footer = searched_ ? ImGui::GetTextLineHeightWithSpacing() : 0; @@ -98,20 +101,21 @@ bool FindSignalDlg::draw() { void FindSignalDlg::drawMessageGroup() { ImGui::BeginDisabled(searching_ || !search_.histories.empty()); + const float field_x = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Address").x + ImGui::GetStyle().ItemSpacing.x; ImGui::TextUnformatted("Messages"); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Bus"); - ImGui::SameLine(80); + ImGui::SameLine(field_x); ImGui::SetNextItemWidth(-1); inputText("##bus", &bus_, "Comma-separated values. Leave blank for all."); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Address"); - ImGui::SameLine(80); + ImGui::SameLine(field_x); ImGui::SetNextItemWidth(-1); inputText("##address", &address_, "Comma-separated hex values. Leave blank for all."); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Time"); - ImGui::SameLine(80); + ImGui::SameLine(field_x); ImGui::SetNextItemWidth(70); validatedText("##first_time", &first_time_, validateDouble); ImGui::SameLine(); @@ -126,29 +130,30 @@ void FindSignalDlg::drawMessageGroup() { void FindSignalDlg::drawPropertiesGroup() { ImGui::BeginDisabled(searching_ || !search_.histories.empty()); + const float field_x = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Factor").x + ImGui::GetStyle().ItemSpacing.x; ImGui::TextUnformatted("Signal"); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Size"); - ImGui::SameLine(80); - ImGui::SetNextItemWidth(70); - if (ImGui::InputInt("##min_size", &min_size_, 1, 10)) min_size_ = std::clamp(min_size_, 1, 64); + ImGui::SameLine(field_x); + ImGui::SetNextItemWidth(inputIntWidth(2)); + if (inputInt("##min_size", &min_size_, 1, 10)) min_size_ = std::clamp(min_size_, 1, 64); ImGui::SameLine(); ImGui::TextUnformatted("-"); ImGui::SameLine(); - ImGui::SetNextItemWidth(70); - if (ImGui::InputInt("##max_size", &max_size_, 1, 10)) max_size_ = std::clamp(max_size_, 1, 64); - ImGui::SameLine(); + ImGui::SetNextItemWidth(inputIntWidth(2)); + if (inputInt("##max_size", &max_size_, 1, 10)) max_size_ = std::clamp(max_size_, 1, 64); + ImGui::SetCursorPosX(field_x); checkBox("Little Endian", &little_endian_); ImGui::SameLine(); checkBox("Signed", &is_signed_); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Factor"); - ImGui::SameLine(80); + ImGui::SameLine(field_x); ImGui::SetNextItemWidth(100); validatedText("##factor", &factor_, validateDouble); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Offset"); - ImGui::SameLine(80); + ImGui::SameLine(field_x); ImGui::SetNextItemWidth(100); validatedText("##offset", &offset_, validateDouble); ImGui::EndDisabled(); diff --git a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc index daffc5408c..1761fd0f51 100644 --- a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc +++ b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc @@ -30,9 +30,10 @@ void FindSimilarBitsDlg::updateMessages() { bool FindSimilarBitsDlg::draw() { if (begin(ImVec2(700, 500))) { + const float field_x = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Find From:").x + ImGui::GetStyle().ItemSpacing.x; ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Find From:"); - ImGui::SameLine(90); + ImGui::SameLine(field_x); ImGui::TextUnformatted("Bus"); ImGui::SameLine(); ImGui::SetNextItemWidth(60); @@ -40,20 +41,21 @@ bool FindSimilarBitsDlg::draw() { ImGui::SameLine(); ImGui::SetNextItemWidth(200); comboBox("##msg", &msg_index_, msg_names_); - ImGui::SameLine(); + ImGui::SetCursorPosX(field_x); + ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Byte Index"); ImGui::SameLine(); - ImGui::SetNextItemWidth(80); - if (ImGui::InputInt("##byte_idx", &byte_idx_, 1, 10)) byte_idx_ = std::clamp(byte_idx_, 0, 63); + ImGui::SetNextItemWidth(inputIntWidth(2)); + if (inputInt("##byte_idx", &byte_idx_, 1, 10)) byte_idx_ = std::clamp(byte_idx_, 0, 63); ImGui::SameLine(); ImGui::TextUnformatted("Bit Index"); ImGui::SameLine(); - ImGui::SetNextItemWidth(80); - if (ImGui::InputInt("##bit_idx", &bit_idx_, 1, 10)) bit_idx_ = std::clamp(bit_idx_, 0, 7); + ImGui::SetNextItemWidth(inputIntWidth(1)); + if (inputInt("##bit_idx", &bit_idx_, 1, 10)) bit_idx_ = std::clamp(bit_idx_, 0, 7); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Find In:"); - ImGui::SameLine(90); + ImGui::SameLine(field_x); ImGui::TextUnformatted("Bus"); ImGui::SameLine(); ImGui::SetNextItemWidth(60); @@ -66,8 +68,8 @@ bool FindSimilarBitsDlg::draw() { ImGui::SameLine(); ImGui::TextUnformatted("Minimum Message Count"); ImGui::SameLine(); - ImGui::SetNextItemWidth(80); - if (ImGui::InputInt("##min_msgs", &min_msgs_, 1, 10)) min_msgs_ = std::max(min_msgs_, 0); + ImGui::SetNextItemWidth(inputIntWidth(4)); + if (inputInt("##min_msgs", &min_msgs_, 1, 10)) min_msgs_ = std::max(min_msgs_, 0); ImGui::SameLine(); if (ImGui::Button("Find")) find(); diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 50553c2d47..69eb94980b 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -71,13 +72,13 @@ bool beginControlChild(const char *id, const ImVec2 &size, ImGuiWindowFlags flag bool clearableInput(const char *label, std::string *s, const char *hint, ImGuiInputTextCallback validator) { const float width = ImGui::CalcItemWidth(); - const float clear_width = iconButtonWidth() + ImGui::GetStyle().ItemInnerSpacing.x; + const float clear_width = iconButtonWidth() + ImGui::GetStyle().ItemSpacing.x; const bool show_clear = !s->empty() && width >= clear_width + ImGui::GetFrameHeight(); ImGui::SetNextItemWidth(show_clear ? width - clear_width : width); ImGui::BeginGroup(); bool changed = validatedInput(label, s, validator, hint); if (show_clear) { - ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + ImGui::SameLine(); ImGui::PushID(label); if (iconButton("clear", icon::X_LG)) { s->clear(); @@ -205,11 +206,10 @@ bool squareIconButton(const char *id, const char *icon) { ImFontBaked *baked = ImGui::GetFont()->GetFontBaked(size); if (const ImFontGlyph *g = baked->FindGlyph((ImWchar)codepoint)) { const GlyphInk ink = cachedGlyphInk(g, size, codepoint); - // Preserve half-logical-pixel positions on HiDPI displays. - const float snap = std::max(1.0f, ImGui::GetIO().DisplayFramebufferScale.x); - auto snapped = [snap](float v) { return std::round(v * snap) / snap; }; - const ImVec2 pos(snapped(r.GetCenter().x - (ink.x0 + ink.x1) * 0.5f), snapped(r.GetCenter().y - (ink.y0 + ink.y1) * 0.5f)); - // AddText truncates to whole logical pixels, undoing the framebuffer snapping above. + // Keep the exact ink center, including half pixels when glyph and button sizes + // have different parity. Rounding the origin shifts small icons off center. + const ImVec2 pos(r.GetCenter().x - (ink.x0 + ink.x1) * 0.5f, r.GetCenter().y - (ink.y0 + ink.y1) * 0.5f); + // AddText truncates the origin, so draw the atlas glyph directly. ImGui::GetWindowDrawList()->AddImage(ImGui::GetIO().Fonts->TexRef, ImVec2(pos.x + g->X0, pos.y + g->Y0), ImVec2(pos.x + g->X1, pos.y + g->Y1), ImVec2(g->U0, g->V0), ImVec2(g->U1, g->V1), ImGui::GetColorU32(ImGuiCol_Text)); @@ -290,7 +290,8 @@ bool dialogEscapePressed() { bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, bool accept_enabled, const char *reject_label) { - const float button_width = 80.0f; + const float button_width = std::max({spacing::DIALOG_BUTTON_MIN_WIDTH, toolbarButtonWidth(accept_label), + reject_label ? toolbarButtonWidth(reject_label) : 0.0f}); const int count = reject_label ? 2 : 1; const float total = button_width * count + ImGui::GetStyle().ItemSpacing.x * (count - 1); const float avail = ImGui::GetContentRegionAvail().x; @@ -316,6 +317,52 @@ bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, boo return pressed; } +float inputIntWidth(int digits) { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::CalcTextSize(std::string(digits, '0').c_str()).x + style.FramePadding.x * 2 + + (ImGui::GetFrameHeight() + style.ItemSpacing.x) * 2; +} + +bool stepButton(const char *id, bool increment, const char *tooltip) { + return iconButton(id, increment ? icon::PLUS_LG : icon::DASH_LG, tooltip); +} + +bool inputInt(const char *label, int *value, int step, int step_fast, ImGuiInputTextFlags flags) { + if (step <= 0) return ImGui::InputInt(label, value, 0, 0, flags); + + const float spacing = ImGui::GetStyle().ItemSpacing.x; + const float width = ImGui::CalcItemWidth(); + ImGui::BeginGroup(); + ImGui::PushID(label); + ImGui::SetNextItemWidth(std::max(1.0f, width - 2 * (iconButtonWidth() + spacing))); + bool changed = ImGui::InputInt("##value", value, 0, 0, flags); + ImGui::BeginDisabled(flags & ImGuiInputTextFlags_ReadOnly); + ImGui::PushItemFlag(ImGuiItemFlags_ButtonRepeat, true); + for (bool increment : {false, true}) { + ImGui::SameLine(0.0f, spacing); + if (stepButton(increment ? "increment" : "decrement", increment)) { + const int amount = ImGui::GetIO().KeyCtrl && step_fast > 0 ? step_fast : step; + const int next = std::clamp(int64_t(*value) + (increment ? int64_t(amount) : -int64_t(amount)), + std::numeric_limits::min(), std::numeric_limits::max()); + if (next != *value) { + *value = next; + changed = true; + } + } + } + ImGui::PopItemFlag(); + ImGui::EndDisabled(); + const char *label_end = ImGui::FindRenderedTextEnd(label); + if (label != label_end) { + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + ImGui::TextUnformatted(label, label_end); + } + ImGui::PopID(); + ImGui::EndGroup(); + if (changed) ImGui::MarkItemEdited(ImGui::GetItemID()); + return changed; +} + int tableHeadersRow() { int clicked = -1; ImGui::TableNextRow(ImGuiTableRowFlags_Headers); @@ -449,16 +496,16 @@ bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWin // tool bar -ToolbarItem toolbarAction(const char *id, const char *icon, const char *label, std::function trigger, bool enabled, bool tight) { +ToolbarItem toolbarAction(const char *id, const char *icon, const char *label, std::function trigger, bool enabled) { return {iconButtonWidth(), [=]() { ImGui::BeginDisabled(!enabled); if (iconButton(id, icon)) trigger(); ImGui::EndDisabled(); disabledItemTooltip(label); - }, label, trigger, enabled, true, tight}; + }, label, trigger, enabled, true}; } -ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *label, std::function items, bool bold, bool tight, float width) { +ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *label, std::function items, bool bold, float width) { if (width <= 0.0f) width = menuButtonWidth(text, bold); ToolbarItem item{width, [id, text, items, bold, width]() { const std::string popup_id = std::string(id) + "_menu"; @@ -468,7 +515,6 @@ ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *lab ImGui::EndPopup(); } }, label}; - item.tight = tight; item.submenu = std::move(items); return item; } @@ -477,13 +523,9 @@ float toolbarButtonWidth(const std::string &label) { return ImGui::CalcTextSize(label.c_str(), nullptr, true).x + ImGui::GetStyle().FramePadding.x * 2; } -static float toolbarSpacing(const ToolbarItem &item) { - return item.tight ? ImGui::GetStyle().ItemInnerSpacing.x : ImGui::GetStyle().ItemSpacing.x; -} - static float toolbarGroupWidth(const std::vector &items, size_t begin, size_t end) { float w = 0; - for (size_t i = begin; i < end; ++i) w += items[i].width + (i > begin ? toolbarSpacing(items[i]) : 0); + for (size_t i = begin; i < end; ++i) w += items[i].width + (i > begin ? ImGui::GetStyle().ItemSpacing.x : 0); return w; } @@ -512,7 +554,7 @@ void drawToolbar(const std::vector &items, size_t spacer_index, flo const float usable = avail - (extension_width + style.ItemSpacing.x); float used = 0; for (visible = 0; visible < items.size(); ++visible) { - const float w = items[visible].width + (visible ? toolbarSpacing(items[visible]) : 0); + const float w = items[visible].width + (visible ? style.ItemSpacing.x : 0); if (used + w > usable) break; used += w; } @@ -521,7 +563,7 @@ void drawToolbar(const std::vector &items, size_t spacer_index, flo for (size_t i = 0; i < visible; ++i) { if (i == 0) ImGui::SetCursorPosX(start_x); else if (fits && i == spacer_index) ImGui::SameLine(right_edge - right_width); - else ImGui::SameLine(0.0f, toolbarSpacing(items[i])); + else ImGui::SameLine(0.0f, ImGui::GetStyle().ItemSpacing.x); items[i].draw(); } @@ -557,11 +599,10 @@ void drawToolbar(const std::vector &items, size_t spacer_index, flo } const float MENU_ARROW_SIZE = 6.0f; // dropdown arrow on a menu button -const float MENU_ARROW_SPACING = 5.0f; // gap between the label and the dropdown arrow float menuButtonWidth(const std::string &text, bool bold) { if (bold) pushBoldFont(); - const float w = ImGui::CalcTextSize(text.c_str(), nullptr, true).x + MENU_ARROW_SPACING + MENU_ARROW_SIZE + + const float w = ImGui::CalcTextSize(text.c_str(), nullptr, true).x + ImGui::GetStyle().ItemInnerSpacing.x + MENU_ARROW_SIZE + ImGui::GetStyle().FramePadding.x * 2; if (bold) popBoldFont(); return w; @@ -576,7 +617,7 @@ bool menuButton(const char *id, const std::string &text, const char *popup_id, b const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; const float ascent = ImGui::GetFontBaked()->Ascent; // the text and the arrow are centered as a group in the button - const float padding_x = std::max(style.FramePadding.x, (width - (text_width + MENU_ARROW_SPACING + MENU_ARROW_SIZE)) * 0.5f); + const float padding_x = std::max(style.FramePadding.x, (width - (text_width + ImGui::GetStyle().ItemInnerSpacing.x + MENU_ARROW_SIZE)) * 0.5f); ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : style.Colors[ImGuiCol_Button]); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(padding_x, style.FramePadding.y)); ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.0f, 0.5f)); @@ -588,7 +629,7 @@ bool menuButton(const char *id, const std::string &text, const char *popup_id, b if (bold) popBoldFont(); // a 6 px arrow right after the text, sitting on the text baseline const ImVec2 min = ImGui::GetItemRectMin(); - const float x = min.x + padding_x + text_width + MENU_ARROW_SPACING; + const float x = min.x + padding_x + text_width + ImGui::GetStyle().ItemInnerSpacing.x; const float baseline = min.y + style.FramePadding.y + ascent; ImGui::GetWindowDrawList()->AddTriangleFilled(ImVec2(x, baseline - MENU_ARROW_SIZE * 0.5f), ImVec2(x + MENU_ARROW_SIZE, baseline - MENU_ARROW_SIZE * 0.5f), diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index e1f368e1ac..f431c9a338 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -83,6 +83,7 @@ inline std::string shortcut(const char *keys) { return std::string(MOD_KEY) + "+ // Use ItemInnerSpacing between related buttons and ItemSpacing between groups. bool iconButton(const char *id, const char *icon, const char *tooltip = nullptr); float iconButtonWidth(); +bool stepButton(const char *id, bool increment, const char *tooltip = nullptr); bool iconTextButton(const char *id, const char *icon, const std::string &text, float width = 0.0f); float iconTextButtonWidth(const char *icon, const std::string &text); @@ -115,6 +116,12 @@ ImGuiWindow *topPopupWindow(); bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, bool accept_enabled = true, const char *reject_label = "Cancel"); +// Numeric inputs keep the same external gaps as other button rows. Width includes +// a readable value plus both step buttons; use for compact fixed-width fields. +float inputIntWidth(int digits); +bool inputInt(const char *label, int *value, int step = 1, int step_fast = 100, + ImGuiInputTextFlags flags = ImGuiInputTextFlags_None); + // horizontal header labels are centered. Returns the column a right click was released on, or -1. int tableHeadersRow(); @@ -168,15 +175,14 @@ struct ToolbarItem { std::function trigger; bool enabled = true; bool in_menu = true; // false: left out of the ">>" menu (a separator) - bool tight = false; // true: ItemInnerSpacing before it, it belongs to the previous item's group std::function submenu; // set: the ">>" entry is a submenu with these items instead of an action }; ToolbarItem toolbarAction(const char *id, const char *icon, const char *label, std::function trigger, - bool enabled = true, bool tight = false); + bool enabled = true); // A drop-down button that opens `items` in a popup; in the overflow menu they become a submenu. // width 0: sized to the text. ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *label, std::function items, - bool bold = false, bool tight = false, float width = 0.0f); + bool bold = false, float width = 0.0f); float toolbarButtonWidth(const std::string &label); // the width of every item plus the spacing between neighbors and the two groups float toolbarWidth(const std::vector &items, size_t spacer_index); diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index cd3d3378a8..39fc68683e 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -102,7 +102,7 @@ void DetailWidget::drawToolBar() { const float panel_width = ImGui::GetWindowWidth(); items.push_back(toolbarAction("edit_msg", icon::PENCIL, "Edit Message", [this, panel_width]() { editMsg(panel_width); })); items.push_back(toolbarAction("remove_msg", icon::TRASH, "Remove Message", - [this]() { UndoStack::instance()->push(new RemoveMsgCommand(msg_id_)); }, action_remove_msg_enabled_, true)); + [this]() { UndoStack::instance()->push(new RemoveMsgCommand(msg_id_)); }, action_remove_msg_enabled_)); const float right_width = toolbarWidth(items, spacer_index) - style.ItemSpacing.x; name_width = std::max(ImGui::CalcTextSize("MMMMMM").x, ImGui::GetContentRegionAvail().x - right_width - style.ItemSpacing.x); @@ -221,13 +221,12 @@ void DetailWidget::drawTabWidget() { // binary_view_ keeps its size hint, signal_view_ takes the rest const float min_height = binary_view_->minimumSizeHint().y; const float avail = ImGui::GetContentRegionAvail().y; - const float max_height = std::max(avail - 6.0f - ImGui::GetStyle().ItemSpacing.y * 2 - 1.0f, 1.0f); + const float max_height = std::max(avail - style.ItemSpacing.y - 1.0f, 1.0f); const float height = std::clamp(min_height, 1.0f, max_height); ImGui::BeginChild("binary_view", ImVec2(0, height), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); binary_view_->draw(); ImGui::EndChild(); - ImGui::Dummy(ImVec2(0.0f, 6.0f)); ImGui::BeginChild("signal_view", ImVec2(0, 0)); signal_view_rect_ = ImGui::GetCurrentWindow()->Rect(); signal_view_->draw(); @@ -338,7 +337,7 @@ bool EditMessageDialog::draw() { setNextDialogWindow(ImVec2(std::clamp(width_, min_width, max_width), 0.0f)); bool open = true; if (ImGui::BeginPopupModal(window_title_.c_str(), &open)) { - const float label_width = ImGui::CalcTextSize("Comment").x + ImGui::GetStyle().ItemSpacing.x * 2; + const float label_width = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Comment").x + ImGui::GetStyle().ItemSpacing.x; auto row = [&](const char *label) { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(label); @@ -356,7 +355,7 @@ bool EditMessageDialog::draw() { } row("Size"); - if (ImGui::InputInt("##size", &size_spin_)) size_spin_ = std::clamp(size_spin_, 1, CAN_MAX_DATA_BYTES); + if (inputInt("##size", &size_spin_)) size_spin_ = std::clamp(size_spin_, 1, CAN_MAX_DATA_BYTES); row("Node"); validatedInput("##node", &node_, nameValidator); diff --git a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc index 75d7c42788..c35e649148 100644 --- a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc +++ b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc @@ -9,7 +9,7 @@ namespace { float scrollButtonsWidth() { const ImGuiStyle &style = ImGui::GetStyle(); - return style.ItemSpacing.x + ImGui::GetFrameHeight() * 2.0f + style.ItemInnerSpacing.x; + return (ImGui::GetFrameHeight() + style.ItemSpacing.x) * 2.0f; } void drawScrollButtons(ImGuiTabBar *tab_bar) { @@ -22,7 +22,7 @@ void drawScrollButtons(ImGuiTabBar *tab_bar) { ImGui::PushItemFlag(ImGuiItemFlags_ButtonRepeat, true); for (int i = 0; i < 2; ++i) { const bool left = i == 0; - ImGui::SetCursorScreenPos(ImVec2(start_x + i * (size + style.ItemInnerSpacing.x), tab_bar->BarRect.Min.y)); + ImGui::SetCursorScreenPos(ImVec2(start_x + i * (size + style.ItemSpacing.x), tab_bar->BarRect.Min.y)); ImGui::BeginDisabled(left ? tab_bar->ScrollingTarget <= 0.0f : tab_bar->ScrollingTarget >= max_scroll); if (ImGui::Button(left ? "###scroll_left" : "###scroll_right", ImVec2(size, size))) { const float step = (left ? -4.0f : 4.0f) * ImGui::GetFontSize(); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index ef18357184..344bf7a4b3 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -334,15 +334,18 @@ void SignalView::drawEditor(SignalModel::Item *item) { drawLineEditor(item, validator, take_focus); } else if (item->type == SignalModel::Item::Size) { - int v = item->sig->size; - if (take_focus) ImGui::SetKeyboardFocusHere(); - bool changed = ImGui::InputInt("##editor", &v, 1, 100, ImGuiInputTextFlags_AutoSelectAll); + if (take_focus) { + edit_int_ = item->sig->size; + ImGui::SetKeyboardFocusHere(); + } + bool changed = inputInt("##editor", &edit_int_, 1, 100, ImGuiInputTextFlags_AutoSelectAll); if (ImGui::IsItemDeactivated() && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { open_item_ = nullptr; // InputInt already reverted the value; only the commit has to be skipped return; } if (ImGui::IsItemDeactivatedAfterEdit() || (changed && !ImGui::IsItemActive())) { - queueCommit(item, std::clamp(v, 1, CAN_MAX_DATA_BYTES)); + edit_int_ = std::clamp(edit_int_, 1, CAN_MAX_DATA_BYTES); + queueCommit(item, edit_int_); } // Enter, Escape and a click outside close the editor; the step buttons keep it open if (ImGui::IsItemDeactivated() && (!ImGui::IsItemHovered() || ImGui::IsKeyPressed(ImGuiKey_Enter, false) || @@ -492,7 +495,7 @@ void SignalView::drawValueDescriptionDlg() { } static ImVec2 indexButtonsSize(float button) { - return ImVec2(button * 2 + ImGui::GetStyle().ItemInnerSpacing.x * 2, button); + return ImVec2(button * 2 + ImGui::GetStyle().ItemSpacing.x, button); } SignalView::SignalView(ChartsWidget *charts) : charts_(charts) { @@ -871,7 +874,7 @@ bool SignalView::drawItem(SignalModel::Item *item, int depth, DrawContext &ctx) } void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { - const float spacing = ImGui::GetStyle().ItemInnerSpacing.x; + const float spacing = ImGui::GetStyle().ItemSpacing.x; const ImVec2 size = indexButtonsSize(iconButtonWidth()); ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - size.x, rect.Min.y + (rect.GetHeight() - size.y) * 0.5f)); @@ -910,12 +913,12 @@ bool ValueDescriptionDlg::draw() { if (!ImGui::BeginPopupModal(popup_id.c_str(), &open, ImGuiWindowFlags_NoSavedSettings)) return ImGui::IsPopupOpen(popup_id.c_str()); bool closing = false; - if (iconButton("add", icon::PLUS_LG, "Add")) { + if (stepButton("add", true, "Add")) { table_.emplace_back("", ""); } - ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + ImGui::SameLine(); ImGui::BeginDisabled(current_row_ == -1); - if (iconButton("remove", icon::DASH_LG, "Remove") && current_row_ < table_.size()) { + if (stepButton("remove", false, "Remove") && current_row_ < table_.size()) { table_.erase(table_.begin() + current_row_); current_row_ = -1; } diff --git a/openpilot/tools/cabana/ui/widgets/signalview.h b/openpilot/tools/cabana/ui/widgets/signalview.h index eaeb6d2aed..dc77ad7b37 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.h +++ b/openpilot/tools/cabana/ui/widgets/signalview.h @@ -190,6 +190,7 @@ private: std::function pending_commit_; SignalModel::Item *editing_item_ = nullptr; // the open text editor std::string edit_text_; + int edit_int_ = 0; bool editor_active_ = false; // editor had the keyboard focus last frame bool refocus_editor_ = false; // reopen the editor rejected by the validator bool enter_pressed_ = false; diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 5122ca96fa..0896951bab 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -25,8 +25,7 @@ const int MIN_VIDEO_HEIGHT = 100; const int THUMBNAIL_MARGIN = 3; const float POINT_10_FONT_SIZE = 13.0f; // 10 pt at 96 dpi const float POINT_16_FONT_SIZE = 21.0f; // 16 pt at 96 dpi -const float TOOLBAR_MARGIN_Y = 6.0f; // between the slider and the buttons, which are as tall as the ones in the charts toolbar -const float TOOLBAR_SEPARATOR_EXTENT = 6.0f; +constexpr float TOOLBAR_SEPARATOR_EXTENT = 1.0f; const float SLIDER_HEIGHT = 15.0f; // the handle plus a 1 px margin // Indexed by TimelineType: None, Engaged, AlertInfo, AlertWarning, AlertCritical, UserBookmark @@ -138,11 +137,11 @@ std::string VideoWidget::whatsThis() const { "Pause/Resume:  space "; } -static float toolbarHeight() { return TOOLBAR_MARGIN_Y + ImGui::GetFrameHeight(); } +static float toolbarHeight() { return ImGui::GetStyle().ItemSpacing.y + ImGui::GetFrameHeight(); } void VideoWidget::drawPlaybackController() { if (!can->liveStreaming()) - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + TOOLBAR_MARGIN_Y); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + ImGui::GetStyle().ItemSpacing.y); const float speed_width = menuButtonWidth("0.05x", true); const char *play_icon = can->isPaused() ? icon::PLAY : icon::PAUSE; @@ -156,11 +155,11 @@ void VideoWidget::drawPlaybackController() { if (!can->liveStreaming()) { items.push_back(toolbarAction("rewind", icon::REWIND, "Seek backward", []() { can->seekTo(can->currentSec() - 1); })); } - items.push_back(toolbarAction("play", play_icon, play_tooltip, []() { can->pause(!can->isPaused()); }, true, true)); + items.push_back(toolbarAction("play", play_icon, play_tooltip, []() { can->pause(!can->isPaused()); })); if (can->liveStreaming()) { - items.push_back(toolbarAction("skip-end", icon::SKIP_END, "Go live", [this]() { skipToEnd(); }, skip_to_end_enabled_, true)); + items.push_back(toolbarAction("skip-end", icon::SKIP_END, "Go live", [this]() { skipToEnd(); }, skip_to_end_enabled_)); } else { - items.push_back(toolbarAction("fast-forward", icon::FAST_FORWARD, "Seek forward", []() { can->seekTo(can->currentSec() + 1); }, true, true)); + items.push_back(toolbarAction("fast-forward", icon::FAST_FORWARD, "Seek forward", []() { can->seekTo(can->currentSec() + 1); })); } if (slider_ || msgs_received_) { // a mono font: with proportional digits the time changed width as it ticked and the items after it moved @@ -186,20 +185,21 @@ void VideoWidget::drawPlaybackController() { const ImVec2 min = ImGui::GetCursorScreenPos(); ImGui::Dummy(ImVec2(TOOLBAR_SEPARATOR_EXTENT, ImGui::GetFrameHeight())); const float x = std::floor(min.x + TOOLBAR_SEPARATOR_EXTENT * 0.5f); - ImGui::GetWindowDrawList()->AddLine(ImVec2(x, min.y + 4.0f), ImVec2(x, min.y + ImGui::GetFrameHeight() - 4.0f), ImGui::GetColorU32(ImGuiCol_Separator)); + const float inset = ImGui::GetStyle().FramePadding.y; + ImGui::GetWindowDrawList()->AddLine(ImVec2(x, min.y + inset), ImVec2(x, min.y + ImGui::GetFrameHeight() - inset), + ImGui::GetColorU32(ImGuiCol_Separator)); }}; item.in_menu = false; - item.tight = true; return item; }; const char *aspect_ratio_icon = settings.crop_video ? icon::ASPECT_RATIO_FILL : icon::ASPECT_RATIO; if (!can->liveStreaming()) { items.push_back(toolbarAction("crop_video", aspect_ratio_icon, "Crop to fill", [this]() { cropVideoClicked(); })); items.push_back(separator()); - items.push_back(toolbarAction("loop", loop_icon, "Loop playback", [this]() { loopPlaybackClicked(); }, true, true)); - items.push_back(toolbarMenu("speed_btn", speed_text_, "Speed", [this]() { drawSpeedMenuItems(); }, true, true, speed_width)); + items.push_back(toolbarAction("loop", loop_icon, "Loop playback", [this]() { loopPlaybackClicked(); })); + items.push_back(toolbarMenu("speed_btn", speed_text_, "Speed", [this]() { drawSpeedMenuItems(); }, true, speed_width)); items.push_back(separator()); - items.push_back(toolbarAction("route_info", icon::INFO_CIRCLE, "View route details", [this]() { showRouteInfo(); }, true, true)); + items.push_back(toolbarAction("route_info", icon::INFO_CIRCLE, "View route details", [this]() { showRouteInfo(); })); } drawToolbar(items, spacer_index); @@ -275,16 +275,20 @@ void VideoWidget::createCameraWidget() { } void VideoWidget::drawCameraWidget() { + const float toolbar_height = toolbarHeight(); + // Camera tabs, video and timeline touch; restore the normal gap for the controls. + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); camera_tab_->draw(); // cam_widget_: minimum height MIN_VIDEO_HEIGHT, takes the space left by the slider and the toolbar const ImVec2 avail = ImGui::GetContentRegionAvail(); - const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, avail.y - SLIDER_HEIGHT - toolbarHeight()); + const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, avail.y - SLIDER_HEIGHT - toolbar_height); cam_widget_->draw(ImVec2(avail.x, cam_height), thumbnail_display_time_); if (!slider_->isSliderDown()) slider_->setCurrentSecond(can->currentSec()); slider_->draw(thumbnail_display_time_); updateSliderThumbnail(); + ImGui::PopStyleVar(); } void VideoWidget::vipcAvailableStreamsUpdated(std::set streams) { @@ -364,12 +368,10 @@ float VideoWidget::defaultHeight(float width) const { } void VideoWidget::draw() { - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); if (!can->liveStreaming()) drawCameraWidget(); drawPlaybackController(); - ImGui::PopStyleVar(); for (auto it = route_info_dlgs_.begin(); it != route_info_dlgs_.end();) { it = (*it)->draw() ? it + 1 : route_info_dlgs_.erase(it); From 20064d9681c6828d482e91de59add4e8e00ecdcf Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:46:51 -0700 Subject: [PATCH 085/270] cabana: show placeholder for unresolved timestamps (#38870) --- openpilot/tools/cabana/ui/widgets/videowidget.cc | 15 ++++++++++----- openpilot/tools/cabana/ui/widgets/videowidget.h | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 0896951bab..7860a95138 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -147,8 +147,13 @@ void VideoWidget::drawPlaybackController() { const char *play_icon = can->isPaused() ? icon::PLAY : icon::PAUSE; const char *play_tooltip = can->isPaused() ? "Play" : "Pause"; const char *loop_icon = getReplay() && getReplay()->loop() ? icon::REPEAT : icon::REPEAT_1; - const std::string time_text = slider_ ? formatTime(can->currentSec(), true) + " / " + formatTime(slider_->maximum() / slider_->factor) - : formatTime(can->currentSec(), true); + const bool timestamps_resolved = can->liveStreaming() + ? msgs_received_ + : can->beginDateTime() != std::chrono::system_clock::time_point{}; + const std::string time_text = timestamps_resolved + ? (slider_ ? formatTime(can->currentSec(), true) + " / " + formatTime(slider_->maximum() / slider_->factor) + : formatTime(can->currentSec(), true)) + : "--:--.-- / --:--.--"; const char *time_tooltip = settings.absolute_time ? "Elapsed time" : "Absolute time"; std::vector items; @@ -161,14 +166,14 @@ void VideoWidget::drawPlaybackController() { } else { items.push_back(toolbarAction("fast-forward", icon::FAST_FORWARD, "Seek forward", []() { can->seekTo(can->currentSec() + 1); })); } - if (slider_ || msgs_received_) { + if (slider_ || timestamps_resolved) { // a mono font: with proportional digits the time changed width as it ticked and the items after it moved - pushMonoFont(ImGui::GetFontSize()); + pushMonoFont(ImGui::GetStyle().FontSizeBase); const float time_width = ImGui::CalcTextSize(time_text.c_str()).x; popMonoFont(); items.push_back({time_width, [&]() { - pushMonoFont(ImGui::GetFontSize()); + pushMonoFont(ImGui::GetStyle().FontSizeBase); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(time_text.c_str()); popMonoFont(); diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h index 1abdbdbe71..94b50dbf3f 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.h +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -112,7 +112,7 @@ private: std::string speed_text_; int speed_index_ = -1; // checked entry of the speed menu bool skip_to_end_enabled_ = true; - bool msgs_received_ = false; // the time is blank until the live stream delivers its first messages + bool msgs_received_ = false; // live-stream timestamps resolve when the first messages arrive double thumbnail_display_time_ = -1; std::unique_ptr slider_; std::unique_ptr camera_tab_; From 1aa97338c470bef73c30fdb520f655bdff1f4c21 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:56:08 -0700 Subject: [PATCH 086/270] Cabana: standardize floating dropdown menus (#38874) * Cabana: standardize floating dropdown menus * Cabana: use uniform items in menus and combo lists * Cabana: remove dropdown test and explanatory comments --- openpilot/tools/cabana/ui/chart/chart.cc | 30 ++--- .../tools/cabana/ui/chart/chartswidget.cc | 10 +- .../tools/cabana/ui/chart/signalselector.cc | 6 +- .../tools/cabana/ui/dialogs/routesdialog.cc | 2 +- .../tools/cabana/ui/dialogs/settingsdialog.cc | 4 +- openpilot/tools/cabana/ui/dropdown.h | 90 +++++++++++++ openpilot/tools/cabana/ui/mainwin.cc | 123 +++++++----------- openpilot/tools/cabana/ui/tools/findsignal.cc | 8 +- .../tools/cabana/ui/tools/findsimilarbits.cc | 2 +- openpilot/tools/cabana/ui/util.cc | 32 ++--- openpilot/tools/cabana/ui/util.h | 12 +- .../tools/cabana/ui/widgets/detailwidget.cc | 10 +- .../tools/cabana/ui/widgets/historylog.cc | 6 +- .../tools/cabana/ui/widgets/messageswidget.cc | 10 +- .../tools/cabana/ui/widgets/signalview.cc | 2 +- .../tools/cabana/ui/widgets/videowidget.cc | 9 +- 16 files changed, 195 insertions(+), 161 deletions(-) create mode 100644 openpilot/tools/cabana/ui/dropdown.h diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 41256845c1..060bc91541 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -49,20 +49,14 @@ ChartView::ChartView(const std::pair &x_range, ChartsWidget *par } void ChartView::drawMenuActions() { - // the current series type is marked with a radio bullet on the left - const float indent = ImGui::GetFontSize(); - float label_width = ImGui::CalcTextSize("Manage Signals").x; - for (const char *type : SERIES_TYPE_NAMES) label_width = std::max(label_width, ImGui::CalcTextSize(type).x); for (int i = 0; i < (int)std::size(SERIES_TYPE_NAMES); ++i) { - if (radioMenuItem(SERIES_TYPE_NAMES[i], i == (int)series_type_, indent + label_width + indent)) { + if (dropdown::Item(SERIES_TYPE_NAMES[i], nullptr, i == (int)series_type_)) { setSeriesType((SeriesType)i); } } ImGui::Separator(); - ImGui::Indent(indent); - if (ImGui::MenuItem("Manage Signals")) manageSignals(); - if (ImGui::MenuItem("Split Chart", nullptr, false, sigs_.size() > 1)) charts_widget_->splitChart(this); - ImGui::Unindent(indent); + if (dropdown::Item("Manage Signals")) manageSignals(); + if (dropdown::Item("Split Chart", nullptr, false, sigs_.size() > 1)) charts_widget_->splitChart(this); } // the buttons and their menus are drawn every frame, at the rects updateLayout() placed them at @@ -72,9 +66,9 @@ void ChartView::createToolButtons() { ImGui::SetCursorScreenPos(layout_.manage_btn_rect.Min); if (iconButton("manage_btn", icon::THREE_DOTS_VERTICAL, "")) ImGui::OpenPopup("manage_menu"); - if (ImGui::BeginPopup("manage_menu")) { + if (dropdown::BeginPopup("manage_menu")) { drawMenuActions(); - ImGui::EndPopup(); + dropdown::EndPopup(); } if (close_clicked) charts_widget_->removeChart(this); @@ -331,23 +325,19 @@ void ChartView::drawContextMenu() { ImGui::OpenPopup("context_menu"); } context_menu_id_ = ImGui::GetID("context_menu"); - if (ImGui::BeginPopup("context_menu")) { + if (dropdown::BeginPopup("context_menu")) { drawMenuActions(); - // the menu holds checkable entries, so every entry keeps the same left margin - const float indent = ImGui::GetFontSize(); - ImGui::Indent(indent); ImGui::Separator(); // the zoom entries come from the toolbar, where they are only visible while zoomed if (can->timeRange().has_value()) { const std::string undo_text = std::string(icon::ARROW_COUNTERCLOCKWISE) + " Undo Zoom"; const std::string redo_text = std::string(icon::ARROW_CLOCKWISE) + " Redo Zoom"; - if (ImGui::MenuItem(undo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canUndo())) charts_widget_->zoom_undo_stack_.undo(); - if (ImGui::MenuItem(redo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canRedo())) charts_widget_->zoom_undo_stack_.redo(); + if (dropdown::Item(undo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canUndo())) charts_widget_->zoom_undo_stack_.undo(); + if (dropdown::Item(redo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canRedo())) charts_widget_->zoom_undo_stack_.redo(); ImGui::Separator(); } - if (ImGui::MenuItem("Close")) charts_widget_->removeChart(this); - ImGui::Unindent(indent); - ImGui::EndPopup(); + if (dropdown::Item("Close")) charts_widget_->removeChart(this); + dropdown::EndPopup(); } } diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index 94a5fa81b2..8052931885 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -43,13 +43,13 @@ ChartsWidget::ChartsWidget() { connections_.push_back(seriesChanged.connect([this]() { updateTabBar(); })); connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { removeTab(index); })); connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { - if (ImGui::BeginPopupContextItem()) { - if (ImGui::MenuItem("Close Other Tabs")) { + if (dropdown::BeginPopupContextItem()) { + if (dropdown::Item("Close Other Tabs")) { tabbar_.moveTab(index, 0); tabbar_.setCurrentIndex(0); while (tabbar_.count() > 1) removeTab(1); } - ImGui::EndPopup(); + dropdown::EndPopup(); } })); connections_.push_back(tabbar_.currentChanged.connect([this](int index) { @@ -180,7 +180,7 @@ void ChartsWidget::drawToolBar() { const std::string chart_type_text = std::string("Type: ") + SERIES_TYPE_NAMES[std::clamp(settings.chart_series_type, 0, type_count - 1)]; auto chart_type_items = [this]() { for (int i = 0; i < type_count; ++i) { - if (ImGui::MenuItem(SERIES_TYPE_NAMES[i], nullptr, settings.chart_series_type == i)) { + if (dropdown::Item(SERIES_TYPE_NAMES[i], nullptr, settings.chart_series_type == i)) { settings.chart_series_type = i; settingChanged(); } @@ -192,7 +192,7 @@ void ChartsWidget::drawToolBar() { if (columns_action_visible_) { auto column_items = [this]() { for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { - if (ImGui::MenuItem(std::to_string(i + 1).c_str(), nullptr, column_count_ == i + 1)) setColumnCount(i + 1); + if (dropdown::Item(std::to_string(i + 1).c_str(), nullptr, column_count_ == i + 1)) setColumnCount(i + 1); } }; items.push_back(toolbarMenu("columns", columns_action_text, "Columns", column_items)); diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc index 1f29a99ac7..2ea6d70121 100644 --- a/openpilot/tools/cabana/ui/chart/signalselector.cc +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -46,7 +46,7 @@ bool SignalSelector::draw() { // a combo popup with a filter box const char *preview = msgs_combo_index_ >= 0 ? msgs_combo_[msgs_combo_index_].text.c_str() : "Select a message..."; ImGui::SetNextItemWidth(column_w); - if (ImGui::BeginCombo("##msgs_combo", preview)) { + if (dropdown::BeginCombo("##msgs_combo", preview)) { if (ImGui::IsWindowAppearing()) { msgs_combo_filter_.clear(); // reopen showing the full list ImGui::SetKeyboardFocusHere(); @@ -55,13 +55,13 @@ bool SignalSelector::draw() { inputText("##msgs_filter", &msgs_combo_filter_, "Select a message..."); for (int i = 0; i < (int)msgs_combo_.size(); ++i) { if (!msgs_combo_filter_.empty() && !utils::containsCI(msgs_combo_[i].text, msgs_combo_filter_)) continue; - if (selectable(msgs_combo_[i].text.c_str(), i == msgs_combo_index_)) { + if (dropdown::Item(msgs_combo_[i].text.c_str(), nullptr, i == msgs_combo_index_)) { msgs_combo_index_ = i; updateAvailableList(i); ImGui::CloseCurrentPopup(); } } - ImGui::EndCombo(); + dropdown::EndCombo(); } bool add_dbl = false; drawList("##available_list", available_list_, &available_row_, false, &add_dbl, ImVec2(column_w, lists_h)); diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc index 9e21464985..f0a5eb0b9e 100644 --- a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -95,7 +95,7 @@ void RoutesDialog::draw() { ImGui::EndDisabled(); } ImGui::SetNextItemWidth(-1.0f); - if (ImGui::Combo("##period", &s_.period_index, PERIOD_NAMES, IM_ARRAYSIZE(PERIOD_NAMES))) fetchRoutes(); + if (dropdown::Combo("##period", &s_.period_index, PERIOD_NAMES, IM_ARRAYSIZE(PERIOD_NAMES))) fetchRoutes(); bool accepted = false, rejected = false; const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; diff --git a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc index 96f10cff1a..c8dd4fb665 100644 --- a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc @@ -60,14 +60,14 @@ void SettingsDialog::draw() { static const char *themes[] = {"Light", "Dark"}; formRow(THEME, label_width); int theme_index = theme_ - LIGHT_THEME; - if (ImGui::Combo("##theme", &theme_index, themes, IM_ARRAYSIZE(themes))) theme_ = theme_index + LIGHT_THEME; + if (dropdown::Combo("##theme", &theme_index, themes, IM_ARRAYSIZE(themes))) theme_ = theme_index + LIGHT_THEME; formRow(CACHED_MINUTES, label_width); settingInputInt("cached_minutes", &cached_minutes_, 1, 10, MIN_CACHE_MINUTES, MAX_CACHE_MINUTES); ImGui::SeparatorText("New Signal Settings"); static const char *directions[] = {"MSB First", "LSB First", "Always Little Endian", "Always Big Endian"}; formRow(DRAG_DIRECTION, label_width); - ImGui::Combo("##drag_direction", &drag_direction_, directions, IM_ARRAYSIZE(directions)); + dropdown::Combo("##drag_direction", &drag_direction_, directions, IM_ARRAYSIZE(directions)); ImGui::SeparatorText("Chart"); formRow(CHART_HEIGHT, label_width); diff --git a/openpilot/tools/cabana/ui/dropdown.h b/openpilot/tools/cabana/ui/dropdown.h new file mode 100644 index 0000000000..dc46d55252 --- /dev/null +++ b/openpilot/tools/cabana/ui/dropdown.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include + +#include "tools/cabana/ui/theme.h" + +namespace dropdown { +constexpr float PADDING_X = 9.0f; +constexpr float PADDING_Y = 6.0f; +constexpr float SPACING_X = 10.0f; +constexpr float SPACING_Y = 8.0f; +constexpr float ROUNDING = 6.0f; +constexpr float BORDER = 1.0f; +constexpr int WINDOW_STYLE_VARS = 3; + +inline void pushStyle() { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(PADDING_X, PADDING_Y)); + ImGui::PushStyleVar(ImGuiStyleVar_PopupRounding, ROUNDING); + ImGui::PushStyleVar(ImGuiStyleVar_PopupBorderSize, BORDER); +} + +inline bool finishBegin(bool open) { + if (open) ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(SPACING_X, SPACING_Y)); + else ImGui::PopStyleVar(WINDOW_STYLE_VARS); + return open; +} +inline bool BeginPopup(const char *id, ImGuiWindowFlags flags = 0) { + pushStyle(); + return finishBegin(ImGui::BeginPopup(id, flags)); +} +inline bool BeginPopupContextItem(const char *id = nullptr, ImGuiPopupFlags flags = ImGuiPopupFlags_MouseButtonRight) { + pushStyle(); + return finishBegin(ImGui::BeginPopupContextItem(id, flags)); +} +inline void EndPopup() { + ImGui::PopStyleVar(); + ImGui::EndPopup(); + ImGui::PopStyleVar(WINDOW_STYLE_VARS); +} +inline bool BeginMenu(const char *label, bool enabled = true) { + pushStyle(); + return finishBegin(ImGui::BeginMenu(label, enabled)); +} +inline void EndMenu() { + ImGui::PopStyleVar(); + ImGui::EndMenu(); + ImGui::PopStyleVar(WINDOW_STYLE_VARS); +} +inline bool BeginCombo(const char *label, const char *preview, ImGuiComboFlags flags = 0) { + pushStyle(); + return finishBegin(ImGui::BeginCombo(label, preview, flags)); +} +inline void EndCombo() { + ImGui::PopStyleVar(); + ImGui::EndCombo(); + ImGui::PopStyleVar(WINDOW_STYLE_VARS); +} + +inline bool Item(const char *label, const char *shortcut = nullptr, bool selected = false, bool enabled = true) { + return ImGui::MenuItem(label, shortcut, selected, enabled); +} +inline bool Item(const char *label, const char *shortcut, bool *selected, bool enabled = true) { + if (!Item(label, shortcut, selected && *selected, enabled)) return false; + if (selected) *selected = !*selected; + return true; +} + +inline bool Combo(const char *label, int *index, const char *const items[], int count) { + bool changed = false; + if (BeginCombo(label, *index >= 0 && *index < count ? items[*index] : "")) { + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + if (Item(items[i], nullptr, i == *index) && i != *index) { + *index = i; + changed = true; + } + if (i == *index) ImGui::SetItemDefaultFocus(); + ImGui::PopID(); + } + EndCombo(); + } + return changed; +} +inline bool Combo(const char *label, int *index, const char *items) { + std::vector labels; + for (const char *item = items; *item; item += std::strlen(item) + 1) labels.push_back(item); + return Combo(label, index, labels.data(), labels.size()); +} +} // namespace dropdown diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 343494a03c..0683116424 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -91,70 +91,47 @@ void MainWindow::loadFingerprints() { void MainWindow::drawFileMenu() { const bool has_stream = hasStream(); - if (ImGui::MenuItem("Open Stream...")) selectAndOpenStream(); - if (ImGui::MenuItem("Close Stream", nullptr, false, has_stream)) closeStream(); - if (ImGui::MenuItem("Export to CSV...", nullptr, false, has_stream)) exportToCSV(); + if (dropdown::Item("Open Stream...")) selectAndOpenStream(); + if (dropdown::Item("Close Stream", nullptr, false, has_stream)) closeStream(); + if (dropdown::Item("Export to CSV...", nullptr, false, has_stream)) exportToCSV(); ImGui::Separator(); - if (ImGui::MenuItem("New DBC File", shortcut("N").c_str())) newFile(); - if (ImGui::MenuItem("Open DBC File...", shortcut("O").c_str())) openFile(); + if (dropdown::Item("New DBC File", shortcut("N").c_str())) newFile(); + if (dropdown::Item("Open DBC File...", shortcut("O").c_str())) openFile(); - if (ImGui::BeginMenu("Manage DBC Files", has_stream)) { + if (dropdown::BeginMenu("Manage DBC Files", has_stream)) { drawManageDBCsMenu(); - ImGui::EndMenu(); + dropdown::EndMenu(); } - if (ImGui::BeginMenu("Open Recent")) { + if (dropdown::BeginMenu("Open Recent")) { drawRecentFilesMenu(); - ImGui::EndMenu(); + dropdown::EndMenu(); } ImGui::Separator(); - if (ImGui::BeginMenu("Load DBC from commaai/opendbc")) { + if (dropdown::BeginMenu("Load DBC from commaai/opendbc")) { for (const auto &name : opendbc_names_) { - if (ImGui::MenuItem(name.c_str())) loadDBCFromOpendbc(name); + if (dropdown::Item(name.c_str())) loadDBCFromOpendbc(name); } - ImGui::EndMenu(); + dropdown::EndMenu(); } - if (ImGui::MenuItem("Load DBC from Clipboard")) loadFromClipboard(); + if (dropdown::Item("Load DBC from Clipboard")) loadFromClipboard(); ImGui::Separator(); const int cnt = dbc()->nonEmptyDBCCount(); const std::string save_text = cnt > 1 ? "Save " + std::to_string(cnt) + " DBCs..." : "Save DBC..."; - if (ImGui::MenuItem(save_text.c_str(), shortcut("S").c_str(), false, cnt > 0)) save(); - if (ImGui::MenuItem("Save DBC As...", shortcut("Shift+S").c_str(), false, cnt == 1)) saveAs(); + if (dropdown::Item(save_text.c_str(), shortcut("S").c_str(), false, cnt > 0)) save(); + if (dropdown::Item("Save DBC As...", shortcut("Shift+S").c_str(), false, cnt == 1)) saveAs(); // TODO: Support clipboard for multiple files - if (ImGui::MenuItem("Copy DBC to Clipboard", nullptr, false, cnt == 1)) saveToClipboard(); + if (dropdown::Item("Copy DBC to Clipboard", nullptr, false, cnt == 1)) saveToClipboard(); ImGui::Separator(); - if (ImGui::MenuItem("Settings...")) openSettings(); + if (dropdown::Item("Settings...")) openSettings(); ImGui::Separator(); - if (ImGui::MenuItem("Exit", shortcut("Q").c_str())) close(); + if (dropdown::Item("Exit", shortcut("Q").c_str())) close(); } -namespace { -bool beginTopMenu(const char *label, bool enabled = true) { - ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImGui::GetColorU32(ImGuiCol_Header)); - const bool open = ImGui::BeginMenu(label, enabled); - ImGui::PopStyleColor(); - if (open) { - // Erase the popup's rounded top border so it joins the menu bar's separator. - const ImGuiStyle &style = ImGui::GetStyle(); - const ImGuiWindow *w = ImGui::GetCurrentWindow(); - const float r = style.PopupRounding, b = style.PopupBorderSize; - const ImVec2 min = w->Pos, max(w->Pos.x + w->Size.x, w->Pos.y + w->Size.y); - const ImU32 bg = ImGui::GetColorU32(ImGuiCol_PopupBg), border = ImGui::GetColorU32(ImGuiCol_Border); - ImDrawList *dl = w->DrawList; - dl->PushClipRect(min, max, false); // the window's own clip rect excludes its border - dl->AddRectFilled(min, ImVec2(max.x, min.y + r), bg); - dl->AddRectFilled(ImVec2(min.x, min.y), ImVec2(min.x + b, min.y + r), border); - dl->AddRectFilled(ImVec2(max.x - b, min.y), ImVec2(max.x, min.y + r), border); - dl->PopClipRect(); - } - return open; -} -} // namespace - void MainWindow::drawMenuBar() { // Avoid a double border with the separator drawn below. ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); @@ -166,43 +143,43 @@ void MainWindow::drawMenuBar() { const ImVec2 max(min.x + ImGui::GetWindowWidth(), min.y + ImGui::GetWindowHeight()); ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(min.x, max.y - 1.0f), max, ImGui::GetColorU32(ImGuiCol_Border)); } - if (beginTopMenu("File")) { + if (dropdown::BeginMenu("File")) { drawFileMenu(); - ImGui::EndMenu(); + dropdown::EndMenu(); } - if (beginTopMenu("Edit")) { + if (dropdown::BeginMenu("Edit")) { auto stack = UndoStack::instance(); const std::string undo_text = stack->canUndo() ? "Undo " + stack->undoText() : "Undo"; const std::string redo_text = stack->canRedo() ? "Redo " + stack->redoText() : "Redo"; - if (ImGui::MenuItem(undo_text.c_str(), shortcut("Z").c_str(), false, stack->canUndo())) stack->undo(); - if (ImGui::MenuItem(redo_text.c_str(), shortcut("Shift+Z").c_str(), false, stack->canRedo())) stack->redo(); - ImGui::EndMenu(); + if (dropdown::Item(undo_text.c_str(), shortcut("Z").c_str(), false, stack->canUndo())) stack->undo(); + if (dropdown::Item(redo_text.c_str(), shortcut("Shift+Z").c_str(), false, stack->canRedo())) stack->redo(); + dropdown::EndMenu(); } - if (beginTopMenu("View")) { - if (ImGui::MenuItem("Full Screen", shortcut("F11").c_str())) toggleFullScreen(); + if (dropdown::BeginMenu("View")) { + if (dropdown::Item("Full Screen", shortcut("F11").c_str())) toggleFullScreen(); ImGui::Separator(); - ImGui::MenuItem(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); - ImGui::MenuItem(video_dock_title_.empty() ? "Video" : video_dock_title_.c_str(), nullptr, &video_visible_); + dropdown::Item(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); + dropdown::Item(video_dock_title_.empty() ? "Video" : video_dock_title_.c_str(), nullptr, &video_visible_); ImGui::Separator(); - if (ImGui::MenuItem("Reset Window Layout")) { + if (dropdown::Item("Reset Window Layout")) { messages_visible_ = video_visible_ = true; video_splitter_ratio_ = -1.0f; reset_layout_ = true; } - ImGui::EndMenu(); + dropdown::EndMenu(); } - if (beginTopMenu("Tools", hasStream())) { - if (ImGui::MenuItem("Find Similar Bits")) findSimilarBits(); - if (ImGui::MenuItem("Find Signal")) findSignal(); - ImGui::EndMenu(); + if (dropdown::BeginMenu("Tools", hasStream())) { + if (dropdown::Item("Find Similar Bits")) findSimilarBits(); + if (dropdown::Item("Find Signal")) findSignal(); + dropdown::EndMenu(); } - if (beginTopMenu("Help")) { - if (ImGui::MenuItem("Help", "F1")) toggleHelp(); - ImGui::EndMenu(); + if (dropdown::BeginMenu("Help")) { + if (dropdown::Item("Help", "F1")) toggleHelp(); + dropdown::EndMenu(); } ImGui::EndMainMenuBar(); } @@ -527,22 +504,22 @@ void MainWindow::drawManageDBCsMenu() { auto dbc_file = dbc()->findDBCFile(source); const std::string title = "Bus " + std::to_string(source) + " (" + (dbc_file ? dbc_file->name() : "No DBCs loaded") + ")"; ImGui::PushID(source); - if (ImGui::BeginMenu(title.c_str())) { - if (ImGui::MenuItem("New DBC File")) newFile(ss); - if (ImGui::MenuItem("Open DBC File...")) openFile(ss); - if (ImGui::MenuItem("Load DBC from Clipboard")) loadFromClipboard(ss, false); + if (dropdown::BeginMenu(title.c_str())) { + if (dropdown::Item("New DBC File")) newFile(ss); + if (dropdown::Item("Open DBC File...")) openFile(ss); + if (dropdown::Item("Load DBC from Clipboard")) loadFromClipboard(ss, false); // Show sub-menu for each dbc for this source. if (dbc_file) { ImGui::Separator(); - ImGui::MenuItem((dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")").c_str(), nullptr, false, false); - if (ImGui::MenuItem("Save...")) saveFile(dbc_file); - if (ImGui::MenuItem("Save As...")) saveFileAs(dbc_file); - if (ImGui::MenuItem("Copy to Clipboard")) saveFileToClipboard(dbc_file); - if (ImGui::MenuItem("Remove from This Bus...")) closeFile(ss, {}); - if (ImGui::MenuItem("Remove from All Buses...")) closeFile(dbc_file); + dropdown::Item((dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")").c_str(), nullptr, false, false); + if (dropdown::Item("Save...")) saveFile(dbc_file); + if (dropdown::Item("Save As...")) saveFileAs(dbc_file); + if (dropdown::Item("Copy to Clipboard")) saveFileToClipboard(dbc_file); + if (dropdown::Item("Remove from This Bus...")) closeFile(ss, {}); + if (dropdown::Item("Remove from All Buses...")) closeFile(dbc_file); } - ImGui::EndMenu(); + dropdown::EndMenu(); } ImGui::PopID(); } @@ -560,14 +537,14 @@ void MainWindow::updateRecentFiles(const std::string &fn) { void MainWindow::drawRecentFilesMenu() { int num_recent_files = std::min(settings.recent_files.size(), MAX_RECENT_FILES); if (!num_recent_files) { - ImGui::MenuItem("No Recent Files", nullptr, false, false); + dropdown::Item("No Recent Files", nullptr, false, false); return; } for (int i = 0; i < num_recent_files; ++i) { std::string text = std::to_string(i + 1) + " " + std::filesystem::path(settings.recent_files[i]).filename().string(); ImGui::PushID(i); - if (ImGui::MenuItem(text.c_str())) loadFile(settings.recent_files[i]); + if (dropdown::Item(text.c_str())) loadFile(settings.recent_files[i]); ImGui::PopID(); } } diff --git a/openpilot/tools/cabana/ui/tools/findsignal.cc b/openpilot/tools/cabana/ui/tools/findsignal.cc index 734107aa9f..f977aa804a 100644 --- a/openpilot/tools/cabana/ui/tools/findsignal.cc +++ b/openpilot/tools/cabana/ui/tools/findsignal.cc @@ -167,7 +167,7 @@ void FindSignalDlg::drawFindGroup() { ImGui::TextUnformatted("Value"); ImGui::SameLine(); ImGui::SetNextItemWidth(90); - ImGui::Combo("##compare", &compare_, compare_items, compare_count); + dropdown::Combo("##compare", &compare_, compare_items, compare_count); ImGui::SameLine(); ImGui::SetNextItemWidth(80); if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); @@ -319,12 +319,12 @@ void FindSignalDlg::setInitialSignals() { } void FindSignalDlg::drawContextMenu(int row) { - if (ImGui::BeginPopupContextItem("menu")) { - if (ImGui::MenuItem("Create Signal")) { + if (dropdown::BeginPopupContextItem("menu")) { + if (dropdown::Item("Create Signal")) { auto &s = search_.filtered_signals[row]; UndoStack::instance()->push(new AddSigCommand(s.id, s.sig)); openMessage(s.id); } - ImGui::EndPopup(); + dropdown::EndPopup(); } } diff --git a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc index 1761fd0f51..032308a9a1 100644 --- a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc +++ b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc @@ -64,7 +64,7 @@ bool FindSimilarBitsDlg::draw() { ImGui::TextUnformatted("Equal"); ImGui::SameLine(); ImGui::SetNextItemWidth(60); - ImGui::Combo("##equal", &equal_, "Yes\0No\0"); + dropdown::Combo("##equal", &equal_, "Yes\0No\0"); ImGui::SameLine(); ImGui::TextUnformatted("Minimum Message Count"); ImGui::SameLine(); diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 69eb94980b..36d0dd8498 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -103,17 +103,17 @@ bool selectable(const char *label, bool selected, ImGuiSelectableFlags flags, co bool comboBox(const char *label, int *index, const std::vector &items) { bool changed = false; const int count = (int)items.size(); - if (ImGui::BeginCombo(label, *index >= 0 && *index < count ? items[*index].c_str() : "")) { + if (dropdown::BeginCombo(label, *index >= 0 && *index < count ? items[*index].c_str() : "")) { for (int i = 0; i < count; ++i) { ImGui::PushID(i); - if (selectable(items[i].c_str(), i == *index) && *index != i) { + if (dropdown::Item(items[i].c_str(), nullptr, i == *index) && *index != i) { *index = i; changed = true; } if (i == *index) ImGui::SetItemDefaultFocus(); ImGui::PopID(); } - ImGui::EndCombo(); + dropdown::EndCombo(); } return changed; } @@ -248,18 +248,6 @@ void disabledItemTooltip(const char *text) { if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip | ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", text); } -bool radioMenuItem(const char *label, bool checked, float width) { - const float indent = ImGui::GetFontSize(); - const ImVec2 pos = ImGui::GetCursorScreenPos(); - const bool clicked = selectable((std::string("##") + label).c_str(), false, ImGuiSelectableFlags_None, - ImVec2(ImMax(width, ImGui::GetContentRegionAvail().x), 0.0f)); - const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); - ImDrawList *painter = ImGui::GetWindowDrawList(); - if (checked) ImGui::RenderBullet(painter, ImVec2(pos.x + indent / 2, pos.y + ImGui::GetTextLineHeight() / 2), color); - painter->AddText(ImVec2(pos.x + indent, pos.y), color, label); - return clicked; -} - bool PopupOwner::begin(const char *id) { ImGuiWindow *window = ImGui::GetCurrentWindowRead(); // GetCurrentWindow() would mark the fallback window as used if (popup_id == 0) { @@ -510,9 +498,9 @@ ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *lab ToolbarItem item{width, [id, text, items, bold, width]() { const std::string popup_id = std::string(id) + "_menu"; menuButton(id, text, popup_id.c_str(), bold, width); - if (ImGui::BeginPopup(popup_id.c_str())) { + if (dropdown::BeginPopup(popup_id.c_str())) { items(); - ImGui::EndPopup(); + dropdown::EndPopup(); } }, label}; item.submenu = std::move(items); @@ -579,21 +567,21 @@ void drawToolbar(const std::vector &items, size_t spacer_index, flo } // the popup opens inward: its right edge is aligned with the button so it stays inside the window ImGui::SetNextWindowPos(ImVec2(ImGui::GetItemRectMax().x, ImGui::GetItemRectMax().y), ImGuiCond_Always, ImVec2(1, 0)); - if (ImGui::BeginPopup("toolbar_extension_menu")) { + if (dropdown::BeginPopup("toolbar_extension_menu")) { for (size_t i = visible; i < items.size(); ++i) { if (!items[i].in_menu) continue; if (items[i].menu_label.empty()) { items[i].draw(); } else if (items[i].submenu) { - if (ImGui::BeginMenu(items[i].menu_label.c_str(), items[i].enabled)) { + if (dropdown::BeginMenu(items[i].menu_label.c_str(), items[i].enabled)) { items[i].submenu(); - ImGui::EndMenu(); + dropdown::EndMenu(); } - } else if (ImGui::MenuItem(items[i].menu_label.c_str(), nullptr, false, items[i].enabled)) { + } else if (dropdown::Item(items[i].menu_label.c_str(), nullptr, false, items[i].enabled)) { items[i].trigger(); } } - ImGui::EndPopup(); + dropdown::EndPopup(); } } } diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index f431c9a338..3f3cabb71a 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -4,7 +4,7 @@ #include #include -#include "tools/cabana/ui/theme.h" +#include "tools/cabana/ui/dropdown.h" #include "tools/cabana/utils/util.h" struct GLFWwindow; @@ -47,17 +47,17 @@ template inline bool comboBox(const char *label, int *index, const T *values, int count) { bool changed = false; const std::string preview = *index >= 0 && *index < count ? std::to_string(values[*index]) : ""; - if (ImGui::BeginCombo(label, preview.c_str())) { + if (dropdown::BeginCombo(label, preview.c_str())) { for (int i = 0; i < count; ++i) { ImGui::PushID(i); - if (selectable(std::to_string(values[i]).c_str(), i == *index) && *index != i) { + if (dropdown::Item(std::to_string(values[i]).c_str(), nullptr, i == *index) && *index != i) { *index = i; changed = true; } if (i == *index) ImGui::SetItemDefaultFocus(); ImGui::PopID(); } - ImGui::EndCombo(); + dropdown::EndCombo(); } return changed; } @@ -90,10 +90,6 @@ float iconTextButtonWidth(const char *icon, const std::string &text); // tooltip for the last item that also shows while the item is disabled void disabledItemTooltip(const char *text); -// exclusive menu action: the bullet sits in the check column and the whole row highlights. `width` is the -// minimum row width, so a narrow popup stays wide enough for every row while the highlight spans the popup. -bool radioMenuItem(const char *label, bool checked, float width = 0.0f); - // A queued modal popup submitted from whichever call site is nested in the top-most modal. draw() is called // both nested in a modal dialog and at the root level; only the level that opened the popup may submit it // (opening at level 0 would make imgui close the parent modal). diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 39fc68683e..944662b7a1 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -86,11 +86,11 @@ void DetailWidget::drawToolBar() { const size_t spacer_index = items.size(); const std::string heatmap_text = "Heatmap: " + (heatmap_live_ ? std::string("Live") : heatmap_all_text_); auto heatmap_items = [this]() { - if (ImGui::MenuItem("Live", nullptr, heatmap_live_) && !heatmap_live_) { + if (dropdown::Item("Live", nullptr, heatmap_live_) && !heatmap_live_) { heatmap_live_ = true; binary_view_->setHeatmapLiveMode(true); } - if (ImGui::MenuItem(heatmap_all_text_.c_str(), nullptr, !heatmap_live_) && heatmap_live_) { + if (dropdown::Item(heatmap_all_text_.c_str(), nullptr, !heatmap_live_) && heatmap_live_) { heatmap_live_ = false; binary_view_->setHeatmapLiveMode(false); } @@ -111,13 +111,13 @@ void DetailWidget::drawToolBar() { } void DetailWidget::showTabBarContextMenu(int index) { - if (ImGui::BeginPopupContextItem()) { - if (ImGui::MenuItem("Close Other Tabs")) { + if (dropdown::BeginPopupContextItem()) { + if (dropdown::Item("Close Other Tabs")) { tabbar_.moveTab(index, 0); tabbar_.setCurrentIndex(0); while (tabbar_.count() > 1) tabbar_.removeTab(1); } - ImGui::EndPopup(); + dropdown::EndPopup(); } } diff --git a/openpilot/tools/cabana/ui/widgets/historylog.cc b/openpilot/tools/cabana/ui/widgets/historylog.cc index 19ce2c9f65..92ed043ea7 100644 --- a/openpilot/tools/cabana/ui/widgets/historylog.cc +++ b/openpilot/tools/cabana/ui/widgets/historylog.cc @@ -142,7 +142,7 @@ void LogsWidget::draw() { const float value_w = std::clamp(ImGui::GetContentRegionAvail().x - fixed, 30.0f, 120.0f); ImGui::SetNextItemWidth(DISPLAY_TYPE_WIDTH); - if (ImGui::Combo("##display_type", &display_type_cb_, "Signal\0Hex\0")) { + if (dropdown::Combo("##display_type", &display_type_cb_, "Signal\0Hex\0")) { hex_mode_ = display_type_cb_; reset(); } @@ -155,10 +155,10 @@ void LogsWidget::draw() { } sig_items += '\0'; ImGui::SetNextItemWidth(SIGNALS_WIDTH); - if (ImGui::Combo("##signals", &signals_cb_, sig_items.c_str())) filterChanged(); + if (dropdown::Combo("##signals", &signals_cb_, sig_items.c_str())) filterChanged(); ImGui::SameLine(); ImGui::SetNextItemWidth(COMPARE_WIDTH); - if (ImGui::Combo("##comp", &comp_box_, ">\0=\0!=\0<\0")) filterChanged(); + if (dropdown::Combo("##comp", &comp_box_, ">\0=\0!=\0<\0")) filterChanged(); ImGui::SameLine(); ImGui::SetNextItemWidth(value_w); if (clearableInput("##value", &value_edit_, "", doubleValidator)) { diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc index 52e0687204..a12942aa83 100644 --- a/openpilot/tools/cabana/ui/widgets/messageswidget.cc +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -301,22 +301,22 @@ void MessagesWidget::suppressHighlighted(bool from_suppress_add) { } void MessagesWidget::drawContextMenu() { - if (!ImGui::BeginPopup("menu")) return; + if (!dropdown::BeginPopup("menu")) return; for (int i = 0; i < MessageList::COLUMN_COUNT; ++i) { const int column = display_order_[i]; // can't hide the name column - if (ImGui::MenuItem(COLUMN_TITLES[column], nullptr, !hidden_[column], column > 0)) { + if (dropdown::Item(COLUMN_TITLES[column], nullptr, !hidden_[column], column > 0)) { pending_hidden_.emplace_back(column, !hidden_[column]); } } ImGui::Separator(); - if (ImGui::MenuItem("Multiline Bytes", nullptr, settings.multiple_lines_hex)) { + if (dropdown::Item("Multiline Bytes", nullptr, settings.multiple_lines_hex)) { setMultiLineBytes(!settings.multiple_lines_hex); } - if (ImGui::MenuItem("Show Inactive Messages", nullptr, list_.show_inactive_messages)) { + if (dropdown::Item("Show Inactive Messages", nullptr, list_.show_inactive_messages)) { list_.showInactiveMessages(!list_.show_inactive_messages); } - ImGui::EndPopup(); + dropdown::EndPopup(); } void MessagesWidget::setMultiLineBytes(bool multi) { diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index 344bf7a4b3..269df6158c 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -375,7 +375,7 @@ void SignalView::drawEditor(SignalModel::Item *item) { } const ImGuiID popup_id = ImHashStr("##ComboPopup", 0, ImGui::GetID("##editor")); if (take_focus) ImGui::SetKeyboardFocusHere(); - if (ImGui::Combo("##editor", ¤t, names.data(), names.size())) { + if (dropdown::Combo("##editor", ¤t, names.data(), names.size())) { queueCommit(item, items[current].second); open_item_ = nullptr; // commit and close the editor } diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 7860a95138..8b269fa8b2 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -234,16 +234,9 @@ void VideoWidget::createSpeedDropdown() { } void VideoWidget::drawSpeedMenuItems() { - // every row declares the same width, so the popup is exactly as wide as the widest one and all the - // highlights reach both edges; the label is padded on the right as much as the check column on the left - const float indent = ImGui::GetFontSize(); - float label_width = 0; - for (int i = 0; i < (int)std::size(speeds); ++i) { - label_width = std::max(label_width, ImGui::CalcTextSize(speedText(speeds[i]).c_str()).x); - } for (int i = 0; i < (int)std::size(speeds); ++i) { const float speed = speeds[i]; - if (radioMenuItem(speedText(speed).c_str(), speed_index_ == i, indent + label_width + indent)) { + if (dropdown::Item(speedText(speed).c_str(), nullptr, speed_index_ == i)) { speed_index_ = i; can->setSpeed(speed); speed_text_ = speedText(speed); From 4c0799eb8e2e6675b7908f93edebaedc8d62d40e Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:40:31 -0700 Subject: [PATCH 087/270] cabana: make Charts a native dockable panel (#38883) * cabana: make charts a native dockable panel * cabana: remove redundant charts docking button --- .../tools/cabana/ui/chart/chartswidget.cc | 23 ---- .../tools/cabana/ui/chart/chartswidget.h | 7 +- openpilot/tools/cabana/ui/inistate.cc | 3 + openpilot/tools/cabana/ui/inistate.h | 1 + openpilot/tools/cabana/ui/mainwin.cc | 103 ++++-------------- openpilot/tools/cabana/ui/mainwin.h | 4 +- .../tools/cabana/ui/widgets/videowidget.cc | 4 +- 7 files changed, 31 insertions(+), 114 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index 8052931885..12bf656a68 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -56,7 +56,6 @@ ChartsWidget::ChartsWidget() { if (index != -1) updateLayout(); })); - setIsDocked(true); newTab(); } @@ -153,11 +152,6 @@ void ChartsWidget::setMaxChartRange(int value) { updateState(); } -void ChartsWidget::setIsDocked(bool docked) { - is_docked_ = docked; - if (!docked) float_window_init_ = true; -} - void ChartsWidget::drawToolBar() { float slider_width = 150.0f; const bool is_zoomed = can->timeRange().has_value(); @@ -170,11 +164,6 @@ void ChartsWidget::drawToolBar() { items.push_back({iconButtonWidth(), [this]() { if (iconButton("new_tab_btn", icon::WINDOW_PLUS, "New Tab")) newTab(); }}); - const std::string title_label = "Charts: " + std::to_string(charts_.size()); - items.push_back({ImGui::CalcTextSize(title_label.c_str()).x, [&title_label]() { - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(title_label.c_str()); - }}); const int type_count = (int)std::size(SERIES_TYPE_NAMES); const std::string chart_type_text = std::string("Type: ") + SERIES_TYPE_NAMES[std::clamp(settings.chart_series_type, 0, type_count - 1)]; @@ -247,9 +236,6 @@ void ChartsWidget::drawToolBar() { }}); } items.push_back(toolbarAction("remove_all_btn", icon::TRASH, "Remove all charts", [this]() { removeAll(); }, !charts_.empty())); - const char *dock_btn_icon = is_docked_ ? icon::BOX_ARROW_UP_RIGHT : icon::BOX_ARROW_IN_DOWN_LEFT; - const char *dock_label = is_docked_ ? "Float the charts window" : "Dock the charts window"; - items.push_back(toolbarAction("dock_btn", dock_btn_icon, dock_label, [this]() { toggleChartsDocking(); })); // the slider shrinks first, the buttons stay pinned to the right edge if (slider_index != (size_t)-1) { @@ -575,15 +561,6 @@ void ChartsWidget::handleEvents() { void ChartsWidget::draw() { deleted_charts_.clear(); - // the floating window is a top level window sized to its contents: keep it inside the main viewport so its - // toolbar stays reachable, then let the user resize it - if (float_window_init_ && !is_docked_) { - float_window_init_ = false; - const ImGuiViewport *viewport = ImGui::GetMainViewport(); - const ImVec2 size(viewport->WorkSize.x * 0.6f, viewport->WorkSize.y * 0.6f); - ImGui::SetWindowSize(size); - ImGui::SetWindowPos(viewport->WorkPos + (viewport->WorkSize - size) * 0.5f); - } ImGui::PushID(this); if (auto_scroll_timer_active_ && ImGui::GetTime() >= auto_scroll_timer_next_) { auto_scroll_timer_next_ = ImGui::GetTime() + 0.05; diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.h b/openpilot/tools/cabana/ui/chart/chartswidget.h index c8c1ef7e13..16e1700631 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.h +++ b/openpilot/tools/cabana/ui/chart/chartswidget.h @@ -68,7 +68,8 @@ class ChartsWidget { public: ChartsWidget(); ~ChartsWidget(); // out of line: the header users only see a forward declared ChartView - void draw(); // content only; MainWindow wraps it in a child region or the floating window + void draw(); // content only; MainWindow owns the dockable panel + size_t chartCount() const { return charts_.size(); } void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); inline bool hasSignal(const MessageId &id, const cabana::Signal *sig) { return findChart(id, sig) != nullptr; } std::vector serializeChartIds() const; @@ -77,9 +78,7 @@ public: void setColumnCount(int n); void removeAll(); - void setIsDocked(bool dock); - Observable<> toggleChartsDocking; Observable<> seriesChanged; Observable showTip; @@ -116,8 +115,6 @@ private: void drawDragPreview(); LogSlider range_slider_{1000}; - bool is_docked_ = true; - bool float_window_init_ = false; // the floating window geometry is set once, right after undocking UndoStack zoom_undo_stack_; diff --git a/openpilot/tools/cabana/ui/inistate.cc b/openpilot/tools/cabana/ui/inistate.cc index 2243690600..4dffec494f 100644 --- a/openpilot/tools/cabana/ui/inistate.cc +++ b/openpilot/tools/cabana/ui/inistate.cc @@ -39,6 +39,8 @@ void readLine(ImGuiContext *, ImGuiSettingsHandler *, void *entry, const char *l state->video_splitter_ratio = ratio; } else if (sscanf(line, "MessagesVisible=%d", &flag) == 1) { state->messages_visible = flag != 0; + } else if (sscanf(line, "ChartsVisible=%d", &flag) == 1) { + state->charts_visible = flag != 0; } else if (sscanf(line, "VideoVisible=%d", &flag) == 1) { state->video_visible = flag != 0; } @@ -54,6 +56,7 @@ void writeAll(ImGuiContext *, ImGuiSettingsHandler *handler, ImGuiTextBuffer *bu buf->appendf("VideoSplitterRatio=%.4f\n", main_window.video_splitter_ratio); buf->appendf("MessagesVisible=%d\n", main_window.messages_visible ? 1 : 0); buf->appendf("VideoVisible=%d\n", main_window.video_visible ? 1 : 0); + buf->appendf("ChartsVisible=%d\n", main_window.charts_visible ? 1 : 0); buf->append("\n"); } diff --git a/openpilot/tools/cabana/ui/inistate.h b/openpilot/tools/cabana/ui/inistate.h index a460802d24..fd17c7e98e 100644 --- a/openpilot/tools/cabana/ui/inistate.h +++ b/openpilot/tools/cabana/ui/inistate.h @@ -15,6 +15,7 @@ struct MainWindowState { float video_splitter_ratio = -1.0f; // < 0: video at its size hint bool messages_visible = true; bool video_visible = true; + bool charts_visible = true; }; extern MainWindowState main_window; diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 0683116424..777f947804 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -38,9 +38,9 @@ constexpr const char *CHARTS_WINDOW = "Charts###ChartsWindow"; MainWindow::MainWindow(GLFWwindow *window, std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file) : window_(window) { can = &dummy_; - video_splitter_ratio_ = inistate::main_window.video_splitter_ratio; messages_visible_ = inistate::main_window.messages_visible; video_visible_ = inistate::main_window.video_visible; + charts_visible_ = inistate::main_window.charts_visible; loadFingerprints(); std::error_code ec; for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH, ec)) { @@ -162,10 +162,10 @@ void MainWindow::drawMenuBar() { ImGui::Separator(); dropdown::Item(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); dropdown::Item(video_dock_title_.empty() ? "Video" : video_dock_title_.c_str(), nullptr, &video_visible_); + dropdown::Item("Charts", nullptr, &charts_visible_); ImGui::Separator(); if (dropdown::Item("Reset Window Layout")) { - messages_visible_ = video_visible_ = true; - video_splitter_ratio_ = -1.0f; + messages_visible_ = video_visible_ = charts_visible_ = true; reset_layout_ = true; } dropdown::EndMenu(); @@ -192,7 +192,6 @@ void MainWindow::createDockWidgets() { charts_widget_ = std::make_unique(); center_widget_.setChartsWidget(charts_widget_.get()); video_widget_ = std::make_unique(); - widget_connections_.push_back(charts_widget_->toggleChartsDocking.connect([this]() { toggleChartsDocking(); })); } void MainWindow::showStatusMessage(const std::string &msg, int timeout_ms) { @@ -578,11 +577,6 @@ void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool succe } } -void MainWindow::toggleChartsDocking() { - charts_floating_ = !charts_floating_; - charts_widget_->setIsDocked(!charts_floating_); -} - void MainWindow::close() { if (closing_) return; closing_ = true; @@ -604,9 +598,9 @@ void MainWindow::finishClose() { glfwGetWindowSize(window_, &state.size[0], &state.size[1]); } state.has_geometry = state.size[0] > 0 && state.size[1] > 0; - state.video_splitter_ratio = video_splitter_ratio_; state.messages_visible = messages_visible_; state.video_visible = video_visible_; + state.charts_visible = charts_visible_; settings.ui_state = inistate::save(); saveSessionState(); @@ -787,15 +781,18 @@ void MainWindow::drawDockspace() { ImGui::SetCursorPosY(ImGui::GetCursorPosY() + top_gap); const ImVec2 dock_size(ImGui::GetContentRegionAvail().x, ImGui::GetContentRegionAvail().y - status_height); const ImGuiID dock_id = ImGui::GetID("cabana_dockspace"); - if (reset_layout_ || ImGui::DockBuilderGetNode(dock_id) == nullptr) { - // messages left, video (with charts) right, center widget in the middle + if (reset_layout_ || ImGui::DockBuilderGetNode(dock_id) == nullptr || + (!ImGui::FindWindowByName(CHARTS_WINDOW) && !ImGui::FindWindowSettingsByID(ImHashStr(CHARTS_WINDOW)))) { + // Messages left, route above charts on the right, details in the middle. ImGui::DockBuilderRemoveNode(dock_id); ImGui::DockBuilderAddNode(dock_id, ImGuiDockNodeFlags_DockSpace); ImGui::DockBuilderSetNodePos(dock_id, ImGui::GetCursorScreenPos()); ImGui::DockBuilderSetNodeSize(dock_id, dock_size); - ImGuiID center = dock_id, left = 0, right = 0; + ImGuiID center = dock_id, left = 0, right = 0, charts = 0; ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.28f, &left, ¢er); ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.4f, &right, ¢er); + ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.55f, &charts, &right); + ImGui::DockBuilderDockWindow(CHARTS_WINDOW, charts); ImGui::DockBuilderDockWindow(MESSAGES_PANEL_ID, left); ImGui::DockBuilderDockWindow(VIDEO_PANEL, right); ImGui::DockBuilderDockWindow(CENTER_PANEL, center); @@ -856,66 +853,10 @@ void MainWindow::drawVideoPanel() { if (video_widget_ && !video_open) { video_widget_->setVisible(false); // the dock is collapsed or tabbed behind another one, like hideEvent } else if (video_widget_) { - const ImVec2 avail = ImGui::GetContentRegionAvail(); - const bool live = can->liveStreaming(); - // the bordered child pads its content, so the heights the widget asks for grow by the padding - const float video_padding = ImGui::GetStyle().WindowPadding.y * 2.0f; - // the camera is as wide as the child's content region, not the panel - const float default_h = video_widget_->defaultHeight(avail.x - ImGui::GetStyle().WindowPadding.x * 2.0f) + video_padding; - const float video_hint = video_splitter_ratio_ >= 0.0f ? avail.y * video_splitter_ratio_ : default_h; - float video_h = charts_floating_ ? avail.y : std::clamp(video_hint, 0.0f, avail.y - 1.0f); - if (live) video_h = default_h; // display video at minimum size. - // Collapse panes below half their minimum height to keep partially clipped controls out of view. - bool charts_collapsed = false; - const float splitter_h = ImGui::GetStyle().WindowPadding.x * 2.0f + 2.0f; - if (!charts_floating_ && !live) { - const float min_h = std::min(video_widget_->sizeHintHeight() + video_padding, avail.y - 1.0f); - video_h = video_h < min_h / 2 ? 0.0f : std::max(video_h, min_h); - const float charts_min_h = ImGui::GetFrameHeight() + video_padding + ImGui::GetStyle().ChildBorderSize * 2.0f; - const float charts_h = avail.y - video_h - splitter_h; - if (charts_h < charts_min_h / 2) { - charts_collapsed = true; - video_h = avail.y - splitter_h; - } else if (charts_h < charts_min_h) { - video_h = avail.y - splitter_h - charts_min_h; - } - } - // Replay uses a splitter for the gap; live streams use normal item spacing. - if (video_h > 0.0f) { - ImGui::BeginChild("video", ImVec2(0, video_h), ImGuiChildFlags_Borders); - help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); - video_widget_->draw(); - ImGui::EndChild(); - // The splitter supplies the pane gap; keep normal spacing inside the video child. - if (!charts_floating_ && !live) ImGui::SetCursorPosY(ImGui::GetCursorPosY() - ImGui::GetStyle().ItemSpacing.y); - } else { - video_widget_->setVisible(false); // the splitter collapsed the video: stop the vipc thread - } - if (!charts_floating_ && !live) { - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); - ImGui::InvisibleButton("##splitter", ImVec2(-1.0f, splitter_h)); - const bool splitter_hovered = ImGui::IsItemHovered() && !live, splitter_active = ImGui::IsItemActive() && !live; - if (splitter_active) { - // the size of the video is the position of the handle inside the splitter - const float top = ImGui::GetWindowPos().y + ImGui::GetCursorStartPos().y; - video_splitter_ratio_ = std::clamp((ImGui::GetMousePos().y - top) / avail.y, 0.0f, 1.0f); - } - if (splitter_hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); - const ImRect splitter(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); - const float line_y = std::floor(splitter.GetCenter().y) - 1.0f; - ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(splitter.Min.x, line_y), ImVec2(splitter.Max.x, line_y + 2.0f), - ImGui::GetColorU32(splitter_active ? ImGuiCol_SeparatorActive : splitter_hovered ? ImGuiCol_SeparatorHovered : ImGuiCol_Border)); - ImGui::PopStyleVar(); - } - if (!charts_floating_) { - if (!charts_collapsed) { - // the chart list scrolls in its own child, the container itself never scrolls - ImGui::BeginChild("charts", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); - charts_widget_->draw(); - ImGui::EndChild(); - } - } + ImGui::BeginChild("video", ImVec2(0, 0), ImGuiChildFlags_Borders); + help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + video_widget_->draw(); + ImGui::EndChild(); } ImGui::End(); if (!video_visible_ && floating) video_visible_ = reset_layout_ = true; @@ -952,16 +893,16 @@ void MainWindow::draw() { if (messages_visible_) drawMessagesPanel(); if (video_widget_ && !video_visible_) video_widget_->setVisible(false); if (video_visible_) drawVideoPanel(); - if (charts_widget_ && charts_floating_) { - bool open = true; - ImGui::SetNextWindowSize(ImGui::GetMainViewport()->WorkSize, ImGuiCond_Appearing); - setNextWindowFloatsOut(); - if (ImGui::Begin(CHARTS_WINDOW, &open, ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { - help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); - charts_widget_->draw(); + if (charts_visible_) { + const std::string charts_title = "Charts: " + std::to_string(charts_widget_ ? charts_widget_->chartCount() : 0) + "###ChartsWindow"; + setNextPanelClass(); + if (beginPanel(charts_title.c_str(), &charts_visible_, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + if (charts_widget_) { + help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + charts_widget_->draw(); + } } ImGui::End(); - if (!open) toggleChartsDocking(); } for (auto it = tool_dialogs_.begin(); it != tool_dialogs_.end();) { it = (*it)->draw() ? it + 1 : tool_dialogs_.erase(it); diff --git a/openpilot/tools/cabana/ui/mainwin.h b/openpilot/tools/cabana/ui/mainwin.h index 72c24a3464..840ca0990a 100644 --- a/openpilot/tools/cabana/ui/mainwin.h +++ b/openpilot/tools/cabana/ui/mainwin.h @@ -26,7 +26,6 @@ public: MainWindow(GLFWwindow *window, std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file); ~MainWindow(); void draw(); - void toggleChartsDocking(); void close(); // remind unsaved changes, save state, exit bool exited() const { return exited_; } void showStatusMessage(const std::string &msg, int timeout_ms = 0); @@ -105,13 +104,12 @@ private: std::string video_dock_title_; bool messages_visible_ = true; bool video_visible_ = true; + bool charts_visible_ = true; bool reset_layout_ = false; bool full_screen_ = false; #ifndef __APPLE__ int windowed_rect_[4] = {0, 0, 1600, 900}; #endif - bool charts_floating_ = false; - float video_splitter_ratio_ = -1.0f; // < 0: the video widget is at its size hint std::vector> tool_dialogs_; bool closing_ = false; bool exited_ = false; diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 8b269fa8b2..5c8a9ede3c 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -278,9 +278,9 @@ void VideoWidget::drawCameraWidget() { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); camera_tab_->draw(); - // cam_widget_: minimum height MIN_VIDEO_HEIGHT, takes the space left by the slider and the toolbar + // Reserve the timeline and playback controls even when the native dock is short. const ImVec2 avail = ImGui::GetContentRegionAvail(); - const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, avail.y - SLIDER_HEIGHT - toolbar_height); + const float cam_height = std::max(1.0f, avail.y - SLIDER_HEIGHT - toolbar_height); cam_widget_->draw(ImVec2(avail.x, cam_height), thumbnail_display_time_); if (!slider_->isSliderDown()) slider_->setCurrentSecond(can->currentSec()); From 44914a7cf4108a31aab86fb96efde9dbcd50c889 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:45:32 -0700 Subject: [PATCH 088/270] cabana: remove custom focus-loss handling (#38884) * cabana: fix focus handling across docked and floating windows * cabana: remove custom focus-loss handling --- openpilot/tools/cabana/ui/app.cc | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/openpilot/tools/cabana/ui/app.cc b/openpilot/tools/cabana/ui/app.cc index e45eaac6fb..beb693b1e3 100644 --- a/openpilot/tools/cabana/ui/app.cc +++ b/openpilot/tools/cabana/ui/app.cc @@ -28,36 +28,6 @@ void keyCallback(GLFWwindow *window, int key, int scancode, int action, int mods ImGui_ImplGlfw_KeyCallback(window, key, scancode, action, mods); if (action == GLFW_PRESS) g_key_events.push_back({key, mods}); } -// imgui releases every mouse button when the window loses focus, which aborts a panel tear-off drag and -// docks the panel back. X11 keeps delivering the drag through the implicit grab, so hold a focus loss back -// while a button is down and deliver it after the release (see deliverPendingFocusLoss). -GLFWwindow *g_focus_lost_window = nullptr; -// macOS drops the button on its own when the focus moves, and holding the loss back there swallowed the -// first click in a popup: the click makes the popup's window key, the main window's loss lands on the -// release and imgui clears its mouse state before it sees that release -void windowFocusCallback(GLFWwindow *w, int f) { -#ifdef __APPLE__ - ImGui_ImplGlfw_WindowFocusCallback(w, f); -#else - if (f) { - g_focus_lost_window = nullptr; - ImGui_ImplGlfw_WindowFocusCallback(w, f); - } else { - g_focus_lost_window = w; - } -#endif -} -bool anyMouseButtonDown(GLFWwindow *w) { - for (int b = GLFW_MOUSE_BUTTON_1; b <= GLFW_MOUSE_BUTTON_LAST; ++b) { - if (glfwGetMouseButton(w, b) == GLFW_PRESS) return true; - } - return false; -} -void deliverPendingFocusLoss() { - if (g_focus_lost_window == nullptr || anyMouseButtonDown(g_focus_lost_window)) return; - ImGui_ImplGlfw_WindowFocusCallback(g_focus_lost_window, GLFW_FALSE); - g_focus_lost_window = nullptr; -} void hookViewportCallbacks() { for (ImGuiViewport *viewport : ImGui::GetPlatformIO().Viewports) { @@ -89,7 +59,6 @@ void paceFrame() { void renderFrame(GLFWwindow *window, MainWindow *win) { glfwPollEvents(); - deliverPendingFocusLoss(); utils::drainMainThreadQueue(); int fb_w = 0, fb_h = 0; @@ -175,7 +144,6 @@ public: throw std::runtime_error("ImGui_ImplGlfw_InitForOpenGL failed"); } glfwSetKeyCallback(window, keyCallback); - glfwSetWindowFocusCallback(window, windowFocusCallback); if (!ImGui_ImplOpenGL3_Init("#version 330")) { ImGui_ImplGlfw_Shutdown(); ImPlot::DestroyContext(); From 0d4a4ab9109e54608580c9154b4c59a8fd0b3e72 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:48:57 -0700 Subject: [PATCH 089/270] cabana: disable collapse for dockable panels (#38885) --- openpilot/tools/cabana/ui/mainwin.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 777f947804..e7c971b6a8 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -825,7 +825,7 @@ void setNextPanelClass() { bool beginPanel(const char *name, bool *open, ImGuiWindowFlags flags = 0) { ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - const bool visible = ImGui::Begin(name, open, flags); + const bool visible = ImGui::Begin(name, open, flags | ImGuiWindowFlags_NoCollapse); ImGui::PopStyleVar(); return visible; } From d38264c42c599c99747002b19c7f01c112880679 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Sat, 12 Sep 2026 23:57:49 -0700 Subject: [PATCH 090/270] log chestnut telemetry in hardwared (#38866) log chestnut hardware state independently of modeld --- openpilot/cereal/log.capnp | 1 + openpilot/cereal/services.py | 3 +- openpilot/selfdrive/modeld/modeld.py | 65 +++---------------- .../system/hardware/chestnut/monitoring.py | 53 +++++++++++++++ openpilot/system/hardware/hardwared.py | 2 + 5 files changed, 67 insertions(+), 57 deletions(-) create mode 100644 openpilot/system/hardware/chestnut/monitoring.py diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 177a220451..086f6ca719 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -2594,6 +2594,7 @@ struct Event { clocks @35 :Clocks; deviceState @6 :DeviceState; chestnutState @152 :ChestnutState; + chestnutGpuState @153 :ChestnutState; logMessage @18 :Text; errorLogMessage @85 :Text; diff --git a/openpilot/cereal/services.py b/openpilot/cereal/services.py index 08633d6975..e8be582072 100755 --- a/openpilot/cereal/services.py +++ b/openpilot/cereal/services.py @@ -25,7 +25,8 @@ _services: dict[str, tuple] = { "accelerometer": (True, 104., 104), "temperatureSensor": (True, 2., 200), "deviceState": (True, 2., 1), - "chestnutState": (True, 10., 10), + "chestnutState": (True, 10., 1), + "chestnutGpuState": (False, 10.), "touch": (True, 20., 1), "can": (True, 100., 2053, QueueSize.BIG), # decimation gives ~3 msgs in a full segment "controlsState": (True, 100., 10, QueueSize.MEDIUM), diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 175c729782..a05361145a 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -5,8 +5,6 @@ from functools import cached_property import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom from tinygrad.device import Device -import usb1 -import struct import threading import time import numpy as np @@ -31,7 +29,6 @@ from openpilot.selfdrive.modeld.parse_model_outputs import Parser from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, nv12_copy_size, MODELD_INPUTS from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState from openpilot.common.file_chunker import open_file_chunked -from openpilot.common.hardware.usb import CHESTNUT_USB_IDS from openpilot.selfdrive.modeld.constants import ModelConstants, Plan from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob @@ -71,45 +68,14 @@ def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log. shouldStop=bool(stop)) -class ChestnutState: - # only modeld can access chestnut +class ChestnutGpuState: + # GPU metrics require modeld's GPU context def __init__(self, pm: PubMaster, big: bool): self.pm = pm self.big = big self.valid = True self.sends = 0 self.metrics = {} - self._asm_usb = None - - def _close_asm_usb(self) -> None: - if self._asm_usb is not None: - self._asm_usb.close() - self._asm_usb = None - - def _open_asm_usb(self): - context = usb1.USBContext() - for vendor_id, product_id in CHESTNUT_USB_IDS: - if (handle := context.openByVendorIDAndProductID(vendor_id, product_id, skip_on_error=True)) is not None: - return handle - context.close() - - def _read_ina(self) -> tuple[int, int, bool]: - if "AMD" in Device._opened_devices and self._asm_usb is None: - try: - raw = Device["AMD"].iface.pci_dev.usb.usb.control_read(0xC0, 5) - return struct.unpack(' int: @@ -117,8 +83,8 @@ class ChestnutState: return smu._send_msg(smu.smu_mod.PPSMC_MSG_GetPptLimit, 0, read_back_arg=True, timeout=100) def send(self) -> None: - msg = messaging.new_message('chestnutState') - state = msg.chestnutState + msg = messaging.new_message('chestnutGpuState') + state = msg.chestnutGpuState self.sends += 1 if self.big and "AMD" in Device._opened_devices and self.sends % 100 == 1: try: @@ -144,21 +110,8 @@ class ChestnutState: for k, v in self.metrics.items(): setattr(state, k, v) - asm_valid = False - try: - # ASM runs on USB-C power, these still read without a gpu - state.supplyVoltage, state.supplyCurrent, state.supplyFault = self._read_ina() - asm_valid = True - except Exception: - pass - if "AMD" in Device._opened_devices: - try: - state.pcieLtssm = Device["AMD"].iface.pci_dev.usb.read(0xB450, 1)[0] - except Exception: - pass - - msg.valid = asm_valid and (not self.big or self.valid) - self.pm.send('chestnutState', msg) + msg.valid = not self.big or (self.valid and bool(self.metrics)) + self.pm.send('chestnutGpuState', msg) class FrameMeta: @@ -295,13 +248,13 @@ def main(demo=False): cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") # messaging - pub_socks = ["modelV2", "drivingModelData", "cameraOdometry"] + (["chestnutState"] if CHESTNUT else []) + pub_socks = ["modelV2", "drivingModelData", "cameraOdometry"] + (["chestnutGpuState"] if CHESTNUT else []) pm = PubMaster(pub_socks) sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) publish_state = PublishState() params = Params() - chestnut_state = ChestnutState(pm, model.chestnut) if CHESTNUT else None + chestnut_state = ChestnutGpuState(pm, model.chestnut) if CHESTNUT else None # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_RUN_FREQ) @@ -410,7 +363,7 @@ def main(demo=False): mt1 = time.perf_counter() try: send_chestnut = (chestnut_state is not None and - run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0) + run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutGpuState'].frequency) == 0) model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None) except Exception: if not params.get_bool("ChestnutActive"): diff --git a/openpilot/system/hardware/chestnut/monitoring.py b/openpilot/system/hardware/chestnut/monitoring.py new file mode 100644 index 0000000000..3a8b21f61b --- /dev/null +++ b/openpilot/system/hardware/chestnut/monitoring.py @@ -0,0 +1,53 @@ +import struct + +import usb1 + +import openpilot.cereal.messaging as messaging +from openpilot.cereal.services import SERVICE_LIST +from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, get_usb_state, is_chestnut_usb_id + + +def read_chestnut_state(handle, gpu_state=None): + msg = messaging.new_message('chestnutState') + if gpu_state is not None: + msg.chestnutState = gpu_state + state = msg.chestnutState + try: + raw = handle.controlRead(0xC0, 0xC0, 0, 0, 5, timeout=100) + state.supplyVoltage, state.supplyCurrent, state.supplyFault = struct.unpack(' Date: Sun, 13 Sep 2026 09:19:48 -0700 Subject: [PATCH 091/270] cabana: make the Signals pane a native dock panel, fix pad (#38889) * cabana: align center tabs and keep signal panel docked * cabana: prevent panels from docking into the tabless center * cabana: use native docking for the Signals panel * cabana: migrate dock tabs when loading the layout --- openpilot/tools/cabana/ui/inistate.cc | 11 +++++++++++ openpilot/tools/cabana/ui/mainwin.cc | 8 ++------ openpilot/tools/cabana/ui/widgets/detailwidget.cc | 3 +++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/openpilot/tools/cabana/ui/inistate.cc b/openpilot/tools/cabana/ui/inistate.cc index 4dffec494f..258344126a 100644 --- a/openpilot/tools/cabana/ui/inistate.cc +++ b/openpilot/tools/cabana/ui/inistate.cc @@ -107,6 +107,15 @@ std::string migrateQtState() { return std::string(buf.c_str()); } +void migrateDockLayout() { + // Show dock tabs hidden by older layouts. + if (const auto *center = ImGui::FindWindowSettingsByID(ImHashStr("###CenterWidget"))) { + if (auto *node = ImGui::DockBuilderGetNode(center->DockId)) { + node->LocalFlags &= ~ImGuiDockNodeFlags_NoTabBar; + } + } +} + } // namespace void addSettingsHandler() { @@ -123,6 +132,8 @@ void load() { if (settings.ui_state.empty()) settings.ui_state = migrateQtState(); if (!settings.ui_state.empty()) ImGui::LoadIniSettingsFromMemory(settings.ui_state.data(), settings.ui_state.size()); + + migrateDockLayout(); } void applyWindowGeometry(GLFWwindow *window) { diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index e7c971b6a8..924c37c0b5 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -31,7 +31,7 @@ namespace { // dock window ids (the visible titles change, the part after ### is the identity) constexpr const char *VIDEO_PANEL = "###VideoPanel"; -constexpr const char *CENTER_PANEL = "###CenterWidget"; +constexpr const char *CENTER_PANEL = "Signals###CenterWidget"; constexpr const char *CHARTS_WINDOW = "Charts###ChartsWindow"; } // namespace @@ -777,8 +777,6 @@ void MainWindow::drawDockspace() { // the status bar sits below the dockspace: reserve its height plus the item spacing between the two, // otherwise the host window is a few pixels taller than the viewport and scrolls const float status_height = full_screen_ ? 0.0f : ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y; - const float top_gap = full_screen_ ? 0.0f : ImGui::GetStyle().ItemSpacing.y; - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + top_gap); const ImVec2 dock_size(ImGui::GetContentRegionAvail().x, ImGui::GetContentRegionAvail().y - status_height); const ImGuiID dock_id = ImGui::GetID("cabana_dockspace"); if (reset_layout_ || ImGui::DockBuilderGetNode(dock_id) == nullptr || @@ -796,7 +794,6 @@ void MainWindow::drawDockspace() { ImGui::DockBuilderDockWindow(MESSAGES_PANEL_ID, left); ImGui::DockBuilderDockWindow(VIDEO_PANEL, right); ImGui::DockBuilderDockWindow(CENTER_PANEL, center); - ImGui::DockBuilderGetNode(center)->LocalFlags |= ImGuiDockNodeFlags_NoTabBar; ImGui::DockBuilderFinish(dock_id); reset_layout_ = false; } @@ -879,13 +876,12 @@ void MainWindow::draw() { drawDockspace(); // the central widget has no scrollbars of its own (the views inside scroll) + setNextPanelClass(); if (beginPanel(CENTER_PANEL, nullptr, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { - ImGui::BeginChild("center", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); center_widget_.draw(); if (auto *detail = center_widget_.getDetailWidget(); detail && help_overlay_.visible()) { for (const auto &[text, rect] : detail->helpRects()) help_overlay_.add(text, rect); } - ImGui::EndChild(); } ImGui::End(); // Submit the same dock windows while loading, so ImGui doesn't collapse their diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 944662b7a1..988cf4b3cb 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -280,6 +280,8 @@ void DetailWidget::drawTabWidget() { void DetailWidget::draw() { tabbar_.draw(); + ImGui::BeginChild("message_content", ImVec2(0, 0), ImGuiChildFlags_Borders, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); drawToolBar(); if (warning_widget_visible_) { @@ -289,6 +291,7 @@ void DetailWidget::draw() { } drawTabWidget(); + ImGui::EndChild(); if (edit_dlg_ && !edit_dlg_->draw()) { if (edit_dlg_->accepted()) { From 064a51fe59af5a9e7fac47f7bee47cdbd2e65371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Sun, 13 Sep 2026 09:38:23 -0700 Subject: [PATCH 092/270] model replay: use a mici segment (#38878) * model replay: use a mici segment * process replay: wait for vision listener startup --- openpilot/selfdrive/test/process_replay/model_replay.py | 4 ++-- openpilot/selfdrive/test/process_replay/process_replay.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/openpilot/selfdrive/test/process_replay/model_replay.py b/openpilot/selfdrive/test/process_replay/model_replay.py index 47ca3fb104..fb46e83ef0 100755 --- a/openpilot/selfdrive/test/process_replay/model_replay.py +++ b/openpilot/selfdrive/test/process_replay/model_replay.py @@ -20,8 +20,8 @@ from openpilot.tools.lib.framereader import FrameReader from openpilot.tools.lib.logreader import LogReader, save_log from openpilot.tools.lib.github_utils import GithubUtils -TEST_ROUTE = "8494c69d3c710e81|000001d4--2648a9a404" -SEGMENT = 4 +TEST_ROUTE = "98395b7c5b27882e|0000002b--2686b5a2d0" +SEGMENT = 1 START_FRAME = 0 END_FRAME = 60 diff --git a/openpilot/selfdrive/test/process_replay/process_replay.py b/openpilot/selfdrive/test/process_replay/process_replay.py index 5abfab2c35..df60d54e88 100755 --- a/openpilot/selfdrive/test/process_replay/process_replay.py +++ b/openpilot/selfdrive/test/process_replay/process_replay.py @@ -17,7 +17,7 @@ from openpilot.common.hardware.hw import Paths import openpilot.cereal.messaging as messaging from opendbc.car.structs import car from openpilot.cereal.services import SERVICE_LIST -from msgq.visionipc import VisionIpcServer, get_endpoint_name as vipc_get_endpoint_name +from msgq.visionipc import VisionIpcClient, VisionIpcServer, get_endpoint_name as vipc_get_endpoint_name from opendbc.car.can_definitions import CanData from opendbc.car.car_helpers import get_car, interfaces from openpilot.common.params import Params @@ -209,6 +209,7 @@ class ProcessContainer: stride, y_height, _, yuv_size = get_nv12_info(frame_size[0], frame_size[1]) vipc_server.create_buffers_with_sizes(meta.stream, 2, frame_size[0], frame_size[1], yuv_size, stride, stride * y_height) vipc_server.start_listener() + VisionIpcClient.available_streams("camerad", block=True) self.vipc_server = vipc_server self.cfg.vision_pubs = [meta.camera_state for meta in streams_metas if meta.camera_state in self.cfg.vision_pubs] From 45a7747ca371148bb94ab861444146f12c1b560d Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:46:43 -0700 Subject: [PATCH 093/270] cabana: fix detached panel menus (#38890) * cabana: keep dropdowns above detached panels and within the screen * cabana: shorten dropdown comments --- openpilot/tools/cabana/ui/dropdown.h | 47 ++++++++++++++++++++++++++++ openpilot/tools/cabana/ui/util.cc | 6 ++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/openpilot/tools/cabana/ui/dropdown.h b/openpilot/tools/cabana/ui/dropdown.h index dc46d55252..b73d8e39a2 100644 --- a/openpilot/tools/cabana/ui/dropdown.h +++ b/openpilot/tools/cabana/ui/dropdown.h @@ -14,6 +14,26 @@ constexpr float ROUNDING = 6.0f; constexpr float BORDER = 1.0f; constexpr int WINDOW_STYLE_VARS = 3; +class PopupViewportScope { +public: + PopupViewportScope() : main_(ImGui::GetMainViewport()), flags_(main_->Flags) { + // Keep detached popups above their owner; reset ownership when docked. + ImGuiWindowClass window_class; + const ImGuiViewport *owner = ImGui::GetWindowViewport(); + if (owner != main_) { + // Popups ignore NoAutoMerge, so exclude the main viewport during Begin. + main_->Flags &= ~ImGuiViewportFlags_CanHostOtherWindows; + window_class.ParentViewportId = owner->ID; + } + ImGui::SetNextWindowClass(&window_class); + } + ~PopupViewportScope() { main_->Flags = flags_; } + +private: + ImGuiViewport *main_; + ImGuiViewportFlags flags_; +}; + inline void pushStyle() { ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(PADDING_X, PADDING_Y)); ImGui::PushStyleVar(ImGuiStyleVar_PopupRounding, ROUNDING); @@ -25,11 +45,36 @@ inline bool finishBegin(bool open) { else ImGui::PopStyleVar(WINDOW_STYLE_VARS); return open; } + +inline void PositionBelowItem(const char *id, bool align_right = false) { + const ImRect anchor(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + char name[32]; + ImFormatString(name, IM_ARRAYSIZE(name), "##Popup_%08x", ImGui::GetID(id)); + if (ImGuiWindow *popup = ImGui::FindWindowByName(name); popup && popup->WasActive) { + // Place like a combo using the owner's current monitor bounds. + const auto *viewport = static_cast(ImGui::GetWindowViewport()); + ImRect bounds = viewport->GetMainRect(); + if ((ImGui::GetIO().ConfigFlags & ImGuiConfigFlags_ViewportsEnable) && viewport->PlatformMonitor >= 0) { + const auto &monitor = ImGui::GetPlatformIO().Monitors[viewport->PlatformMonitor]; + bounds = ImRect(monitor.WorkPos, ImVec2(monitor.WorkPos.x + monitor.WorkSize.x, monitor.WorkPos.y + monitor.WorkSize.y)); + } + bounds.Expand(ImVec2(-ImGui::GetStyle().DisplaySafeAreaPadding.x, -ImGui::GetStyle().DisplaySafeAreaPadding.y)); + ImGuiDir direction = align_right ? ImGuiDir_Left : ImGuiDir_Down; + const ImVec2 pos = ImGui::FindBestWindowPosForPopupEx(anchor.GetBL(), ImGui::CalcWindowNextAutoFitSize(popup), + &direction, bounds, anchor, ImGuiPopupPositionPolicy_ComboBox); + ImGui::SetNextWindowPos(pos); + } else { + ImGui::SetNextWindowPos(align_right ? anchor.GetBR() : anchor.GetBL(), ImGuiCond_Always, ImVec2(align_right ? 1.0f : 0.0f, 0)); + } +} + inline bool BeginPopup(const char *id, ImGuiWindowFlags flags = 0) { + PopupViewportScope viewport_scope; pushStyle(); return finishBegin(ImGui::BeginPopup(id, flags)); } inline bool BeginPopupContextItem(const char *id = nullptr, ImGuiPopupFlags flags = ImGuiPopupFlags_MouseButtonRight) { + PopupViewportScope viewport_scope; pushStyle(); return finishBegin(ImGui::BeginPopupContextItem(id, flags)); } @@ -39,6 +84,7 @@ inline void EndPopup() { ImGui::PopStyleVar(WINDOW_STYLE_VARS); } inline bool BeginMenu(const char *label, bool enabled = true) { + PopupViewportScope viewport_scope; pushStyle(); return finishBegin(ImGui::BeginMenu(label, enabled)); } @@ -48,6 +94,7 @@ inline void EndMenu() { ImGui::PopStyleVar(WINDOW_STYLE_VARS); } inline bool BeginCombo(const char *label, const char *preview, ImGuiComboFlags flags = 0) { + PopupViewportScope viewport_scope; pushStyle(); return finishBegin(ImGui::BeginCombo(label, preview, flags)); } diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 36d0dd8498..64e1b914be 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -565,8 +565,7 @@ void drawToolbar(const std::vector &items, size_t spacer_index, flo (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)))) { ImGui::OpenPopup("toolbar_extension_menu"); } - // the popup opens inward: its right edge is aligned with the button so it stays inside the window - ImGui::SetNextWindowPos(ImVec2(ImGui::GetItemRectMax().x, ImGui::GetItemRectMax().y), ImGuiCond_Always, ImVec2(1, 0)); + dropdown::PositionBelowItem("toolbar_extension_menu", true); if (dropdown::BeginPopup("toolbar_extension_menu")) { for (size_t i = visible; i < items.size(); ++i) { if (!items[i].in_menu) continue; @@ -624,8 +623,7 @@ bool menuButton(const char *id, const std::string &text, const char *popup_id, b ImVec2(x + MENU_ARROW_SIZE * 0.5f, baseline), ImGui::GetColorU32(ImGuiCol_TextDisabled)); if (clicked && !popup_open) ImGui::OpenPopup(popup_id); - // the menu drops down from below the button, not at the mouse cursor - ImGui::SetNextWindowPos(ImVec2(min.x, ImGui::GetItemRectMax().y), ImGuiCond_Always); + dropdown::PositionBelowItem(popup_id); return clicked; } From c5cf29cf5e0e8090c7c3150dc42500690ec2e19f Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:53:35 -0700 Subject: [PATCH 094/270] cabana: contain tooltips and capture plot drags (#38896) * cabana: contain chart tooltips within columns * cabana: refresh tooltip bounds after chart resize * cabana: capture plot drags to prevent window movement --- openpilot/tools/cabana/ui/chart/chart.cc | 14 ++++++-- openpilot/tools/cabana/ui/chart/tiplabel.cc | 40 +++++++++++++++++++-- openpilot/tools/cabana/ui/chart/tiplabel.h | 3 +- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 060bc91541..4f1d7891ea 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -507,8 +507,10 @@ void ChartView::draw(float width) { } ImGui::EndChild(); // a chart scrolled out of the viewport draws no tip - const ImRect visible_rect = charts_widget_->chartVisibleRect(this); - if (!drawing_ghost_ && visible_rect.GetWidth() > 0 && visible_rect.GetHeight() > 0) tip_label_.draw(); + ImRect visible_rect = charts_widget_->chartVisibleRect(this); + visible_rect.ClipWith(ImRect(ImVec2(layout_.rect.Min.x, layout_.plot_area.Min.y), + ImVec2(layout_.rect.Max.x, layout_.plot_area.Max.y))); + if (!drawing_ghost_ && visible_rect.GetWidth() > 0 && visible_rect.GetHeight() > 0) tip_label_.draw(visible_rect); ImGui::PopID(); } @@ -575,6 +577,14 @@ void ChartView::drawAxes() { // ImPlotFlags_NoInputs disables implot's own hover tracking layout_.plot_hovered = layout_.plot_area.Contains(ImGui::GetMousePos()) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); drawSeries(); + if (!drawing_ghost_) { + // Own plot clicks so custom scrubbing/zooming cannot also move the floating window. + const ImGuiID input_id = ImGui::GetID("plot_input"); + if (ImGui::ItemAdd(layout_.plot_area, input_id)) { + bool hovered, held; + ImGui::ButtonBehavior(layout_.plot_area, input_id, &hovered, &held); + } + } handleMousePress(); handleMouseMove(); handleMouseRelease(); diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.cc b/openpilot/tools/cabana/ui/chart/tiplabel.cc index 0efc4e7ad6..6f3a1ee54e 100644 --- a/openpilot/tools/cabana/ui/chart/tiplabel.cc +++ b/openpilot/tools/cabana/ui/chart/tiplabel.cc @@ -19,6 +19,31 @@ ImVec2 TipLabel::layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) cons }; const char *heading = !text_.empty() && !text_[0].has_marker ? text_[0].name.c_str() : "Signal"; const char *headers[] = {heading, "Value", "Min", "Max"}; + if (compact_) { + const float right = origin.x + std::max(0.0f, area_.GetWidth() - MARGIN * 2 - 1); + auto cell = [&](float left, const std::string &text, ImU32 color) { + if (p) drawElidedText(p, ImRect(ImVec2(left, y), ImVec2(right, y + font_size)), text, color); + }; + cell(origin.x, heading, muted); + y += line_height; + for (const auto &line : text_) { + if (!line.has_marker) continue; + if (p) p->AddRectFilled(ImVec2(origin.x, y + (font_size - marker) * 0.5f), + ImVec2(origin.x + marker, y + (font_size + marker) * 0.5f), line.marker); + cell(origin.x + marker + 6, line.name, fg); + y += line_height; + const std::string *values[] = {&line.value, &line.min, &line.max}; + const float label_width = ImGui::CalcTextSize("Value").x + gap; + for (int i = 0; i < 3; ++i) { + draw(origin.x, headers[i + 1], muted); + pushMonoFont(font_size); + cell(origin.x + label_width, *values[i], i == 0 ? fg : muted); + popMonoFont(); + y += line_height; + } + } + return ImVec2(right - origin.x, y - origin.y); + } float x = origin.x; for (int i = 0; i < 4; ++i) { draw(i ? x + column_widths_[i] - ImGui::CalcTextSize(headers[i]).x : x, headers[i], muted); @@ -77,8 +102,15 @@ void TipLabel::updateLayout() { } popMonoFont(); const ImGuiViewport *viewport = ImGui::GetWindowViewport(); - const ImRect bounds(viewport->WorkPos, viewport->WorkPos + viewport->WorkSize); + ImRect bounds(viewport->WorkPos, viewport->WorkPos + viewport->WorkSize); + bounds.ClipWith(area_); + if (bounds.GetWidth() <= 0 || bounds.GetHeight() <= 0) { + visible_ = false; + return; + } + area_ = bounds; const float numeric_width = column_widths_[1] + column_widths_[2] + column_widths_[3] + 24 + MARGIN * 2 + 1; + compact_ = bounds.GetWidth() < numeric_width + ImGui::GetFontSize() * 4; column_widths_[0] = std::min(column_widths_[0], std::max(40.0f, std::min(ImGui::GetFontSize() * 16, bounds.GetWidth() - numeric_width))); if (!text_.empty()) { ImVec2 extra(1, 1); @@ -96,13 +128,17 @@ void TipLabel::updateLayout() { visible_ = false; } -void TipLabel::draw() { +void TipLabel::draw(const ImRect &rect) { if (!visible_) return; + area_ = rect; updateLayout(); + if (!visible_) return; ImDrawList *p = ImGui::GetForegroundDrawList(); + p->PushClipRect(area_.Min, area_.Max, true); // filled panel with a 1px frame p->AddRectFilled(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_PopupBg), ImGui::GetStyle().PopupRounding); p->AddRect(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_Border), ImGui::GetStyle().PopupRounding); layoutLines(p, pos_ + ImVec2(MARGIN, MARGIN), ImGui::GetColorU32(ImGuiCol_Text)); + p->PopClipRect(); } diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.h b/openpilot/tools/cabana/ui/chart/tiplabel.h index fe7b099d37..48f20b59f8 100644 --- a/openpilot/tools/cabana/ui/chart/tiplabel.h +++ b/openpilot/tools/cabana/ui/chart/tiplabel.h @@ -20,7 +20,7 @@ public: void showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect); void hide() { visible_ = false; } bool isVisible() const { return visible_; } - void draw(); // draws the tip on the foreground draw list; call once per frame + void draw(const ImRect &rect); // draws the tip on the foreground draw list; call once per frame private: // lays the lines out from origin, drawing them when p is given; returns the size of the text block @@ -35,5 +35,6 @@ private: ImRect area_; ImVec2 pos_; ImVec2 size_; + bool compact_ = false; bool visible_ = false; }; From 9c054e285d8980daae01b45672c46899f3af237d Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:19:59 -0700 Subject: [PATCH 095/270] replay: retry failed segment loads (#38897) * replay: retry failed segment loads * replay: rename test helper for spellcheck * replay: include portable temporary directory declaration * replay: name attempt limit and shorten test delays * replay: drop timing-dependent retry tests --- openpilot/tools/replay/seg_mgr.cc | 29 ++++++++++++++++++++++++++--- openpilot/tools/replay/seg_mgr.h | 9 +++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/openpilot/tools/replay/seg_mgr.cc b/openpilot/tools/replay/seg_mgr.cc index 074e7e53db..2c616bd004 100644 --- a/openpilot/tools/replay/seg_mgr.cc +++ b/openpilot/tools/replay/seg_mgr.cc @@ -51,10 +51,11 @@ void SegmentManager::setCurrentSegment(int seg_num) { void SegmentManager::manageSegmentCache() { while (true) { std::unique_lock lock(mutex_); - cv_.wait(lock, [this]() { return exit_ || needs_update_; }); + cv_.wait_until(lock, next_retry_, [this]() { return exit_ || needs_update_; }); if (exit_) break; needs_update_ = false; + next_retry_ = std::chrono::steady_clock::time_point::max(); auto cur = segments_.lower_bound(cur_seg_num_); if (cur == segments_.end()) continue; @@ -69,8 +70,12 @@ void SegmentManager::manageSegmentCache() { bool merged = mergeSegments(begin, end); // Free segments outside the current range - std::for_each(segments_.begin(), begin, [](auto &segment) { segment.second.reset(); }); - std::for_each(end, segments_.end(), [](auto &segment) { segment.second.reset(); }); + auto evict = [this](auto &segment) { + segment.second.reset(); + load_attempts_.erase(segment.first); + }; + std::for_each(segments_.begin(), begin, evict); + std::for_each(end, segments_.end(), evict); if (merged && onSegmentMergedCallback_) { onSegmentMergedCallback_(); // Notify listener that segments have been merged @@ -121,7 +126,25 @@ void SegmentManager::loadSegmentsInRange(SegmentMap::iterator begin, SegmentMap: auto tryLoadSegment = [this](auto first, auto last) { for (auto it = first; it != last; ++it) { auto &segment_ptr = it->second; + auto &attempt = load_attempts_[it->first]; + if (segment_ptr && segment_ptr->getState() == Segment::LoadState::Failed) { + // A failed object must not permanently occupy its cache slot. Back off + // between retries while allowing other segments to load meanwhile. + if (attempt.count >= MAX_SEGMENT_LOAD_ATTEMPTS) continue; + const auto now = std::chrono::steady_clock::now(); + if (attempt.retry_at == std::chrono::steady_clock::time_point::max()) { + attempt.retry_at = now + std::chrono::seconds(attempt.count); + } + if (now < attempt.retry_at) { + next_retry_ = std::min(next_retry_, attempt.retry_at); + continue; + } + segment_ptr.reset(); + rWarning("retrying segment %d (attempt %d/%d)", it->first, attempt.count + 1, MAX_SEGMENT_LOAD_ATTEMPTS); + } if (!segment_ptr) { + ++attempt.count; + attempt.retry_at = std::chrono::steady_clock::time_point::max(); if (onBenchmarkEvent_) { onBenchmarkEvent_(it->first, "loading"); } diff --git a/openpilot/tools/replay/seg_mgr.h b/openpilot/tools/replay/seg_mgr.h index 55b412b25a..6542335caf 100644 --- a/openpilot/tools/replay/seg_mgr.h +++ b/openpilot/tools/replay/seg_mgr.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -9,6 +10,7 @@ #include "tools/replay/route.h" constexpr int MIN_SEGMENTS_CACHE = 5; +constexpr int MAX_SEGMENT_LOAD_ATTEMPTS = 3; // Includes the initial load. using SegmentMap = std::map>; @@ -56,4 +58,11 @@ private: std::function onSegmentMergedCallback_ = nullptr; std::function onBenchmarkEvent_ = nullptr; std::set merged_segments_; + struct LoadAttempt { + int count = 0; + std::chrono::steady_clock::time_point retry_at = std::chrono::steady_clock::time_point::max(); + }; + // Accessed only by the cache management thread. + std::map load_attempts_; + std::chrono::steady_clock::time_point next_retry_ = std::chrono::steady_clock::time_point::max(); }; From ebb202f29d5847a6d57387aa064c5004f0ce7b38 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:35:46 -0700 Subject: [PATCH 096/270] tools: share cancellable browser sign-in (#38893) * tools: share cancellable browser sign-in * tools: invoke auth JSON mode directly from Cabana * tools: require an explicit Python module * tools: order module constants alphabetically * tools: use shared unauthorized handling for devices --- openpilot/tools/lib/auth.py | 101 ++++++++++++++++-------- openpilot/tools/lib/auth_config.py | 3 +- openpilot/tools/replay/py_downloader.cc | 23 ++++-- openpilot/tools/replay/py_downloader.h | 3 + 4 files changed, 88 insertions(+), 42 deletions(-) diff --git a/openpilot/tools/lib/auth.py b/openpilot/tools/lib/auth.py index 6a685e38d9..02df4954a5 100755 --- a/openpilot/tools/lib/auth.py +++ b/openpilot/tools/lib/auth.py @@ -22,34 +22,50 @@ Examples:: """ import argparse +import json import sys +import subprocess import pprint -import webbrowser -from http.server import BaseHTTPRequestHandler, HTTPServer +import time +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any -from urllib.parse import parse_qs, urlencode +from urllib.parse import parse_qs, urlencode, urlsplit -from openpilot.tools.lib.api import APIError, CommaApi, UnauthorizedError +from openpilot.tools.lib.api import CommaApi, UnauthorizedError from openpilot.tools.lib.auth_config import set_token, get_token -class ClientRedirectServer(HTTPServer): - query_params: dict[str, Any] = {} +class ClientRedirectServer(ThreadingHTTPServer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.query_params: dict[str, Any] = {} + self.result_lock = threading.Lock() + + def get_request(self): + request, address = super().get_request() + request.settimeout(1) # Bound incomplete requests, including browser preconnections. + return request, address class ClientRedirectHandler(BaseHTTPRequestHandler): def do_GET(self): - if not self.path.startswith('/auth'): + if urlsplit(self.path).path not in ('/auth', '/auth/'): self.send_response(204) + self.end_headers() return - query = self.path.split('?', 1)[-1] - query_parsed = parse_qs(query, keep_blank_values=True) - self.server.query_params = query_parsed + query_parsed = parse_qs(urlsplit(self.path).query, keep_blank_values=True) + with self.server.result_lock: + if not self.server.query_params and ('code' in query_parsed or 'error' in query_parsed): + self.server.query_params = query_parsed self.send_response(200) self.send_header('Content-type', 'text/plain') self.end_headers() - self.wfile.write(b'Return to the CLI to continue') + try: + self.wfile.write(b'Sign-in received. You can close this tab and return to Cabana or your terminal.') + except ConnectionError: + pass # A closing browser tab must not discard the received callback. def log_message(self, format: str, *args: object) -> None: # noqa: A002 # stdlib override pass # this prevent http server from dumping messages to stdout @@ -94,36 +110,52 @@ def auth_redirect_link(method, port): raise NotImplementedError(f"no redirect implemented for method {method}") -def login(method): - # Let the OS select an available port to avoid colliding with other services. - web_server = ClientRedirectServer(('localhost', 0), ClientRedirectHandler) - oauth_uri = auth_redirect_link(method, web_server.server_port) - print(f'To sign in, use your browser and navigate to {oauth_uri}') - webbrowser.open(oauth_uri, new=2) - - while True: - web_server.handle_request() - if 'code' in web_server.query_params: - break - elif 'error' in web_server.query_params: - print('Authentication Error: "{}". Description: "{}" '.format( - web_server.query_params['error'], - web_server.query_params.get('error_description')), file=sys.stderr) - break - +def login(method, timeout=180): + """Sign in through a browser and save the token, returning a success/error status.""" try: - auth_resp = CommaApi().post('v2/auth/', data={'code': web_server.query_params['code'], 'provider': web_server.query_params['provider']}) - set_token(auth_resp['access_token']) - except APIError as e: - print(f'Authentication Error: {e}', file=sys.stderr) + with ClientRedirectServer(('localhost', 0), ClientRedirectHandler) as server: + url = auth_redirect_link(method, server.server_port) + print(f'To sign in, use your browser and navigate to {url}', file=sys.stderr) + browser = subprocess.Popen(['open' if sys.platform == 'darwin' else 'xdg-open', url], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + server.timeout = min(0.1, max(0, deadline - time.monotonic())) + server.handle_request() + params = server.query_params + if 'error' in params: + return {"error": "Sign-in was declined. Choose a provider to try again."} + if 'code' in params: + provider = {'google': 'g', 'apple': 'a', 'github': 'h'}[method] + if len(params['code']) != 1 or not params['code'][0].strip() or params.get('provider') != [provider]: + return {"error": "Invalid sign-in response. Please try again."} + response = CommaApi().post('v2/auth/', data={'code': params['code'], 'provider': params['provider']}, timeout=30) + token = response.get('access_token') + if not isinstance(token, str) or not token.strip(): + return {"error": "Sign-in did not return an access token. Please try again."} + CommaApi(token).get('v1/me', timeout=30) + set_token(token) + return {"success": True} + if browser.poll() not in (None, 0): + return {"error": "Could not open your browser. Check your default browser and try again."} + return {"error": "Sign-in timed out. Choose a provider to try again."} + except Exception: + return {"error": "Could not complete sign-in. Check your connection and try again."} if __name__ == '__main__': parser = argparse.ArgumentParser(description='Login to your comma account') parser.add_argument('method', default='google', const='google', nargs='?', choices=['google', 'apple', 'github', 'jwt']) parser.add_argument('jwt', nargs='?') + parser.add_argument('--json', action='store_true', help='Return browser sign-in status as JSON') args = parser.parse_args() + if args.json: + if args.method == 'jwt': + parser.error('--json requires a browser sign-in provider') + print(json.dumps(login(args.method))) + sys.exit(0) + if args.method == 'jwt': if args.jwt is None: print("method JWT selected, but no JWT was provided") @@ -131,7 +163,10 @@ if __name__ == '__main__': set_token(args.jwt) else: - login(args.method) + result = login(args.method) + if "error" in result: + print(result["error"], file=sys.stderr) + sys.exit(1) try: me = CommaApi(token=get_token()).get('/v1/me') diff --git a/openpilot/tools/lib/auth_config.py b/openpilot/tools/lib/auth_config.py index 5966bd34a9..954225d4d3 100644 --- a/openpilot/tools/lib/auth_config.py +++ b/openpilot/tools/lib/auth_config.py @@ -1,6 +1,7 @@ import json import os from openpilot.common.hardware.hw import Paths +from openpilot.common.utils import atomic_write class MissingAuthConfigError(Exception): @@ -18,7 +19,7 @@ def get_token(): def set_token(token): os.makedirs(Paths.config_root(), exist_ok=True) - with open(os.path.join(Paths.config_root(), 'auth.json'), 'w') as f: + with atomic_write(os.path.join(Paths.config_root(), 'auth.json'), overwrite=True) as f: json.dump({'access_token': token}, f) diff --git a/openpilot/tools/replay/py_downloader.cc b/openpilot/tools/replay/py_downloader.cc index a7ab5baa91..286d8a7319 100644 --- a/openpilot/tools/replay/py_downloader.cc +++ b/openpilot/tools/replay/py_downloader.cc @@ -18,6 +18,9 @@ namespace { +constexpr const char *AUTH_MODULE = "openpilot.tools.lib.auth"; +constexpr const char *DOWNLOADER_MODULE = "openpilot.tools.lib.file_downloader"; + static std::mutex handler_mutex; static DownloadProgressHandler progress_handler = nullptr; @@ -30,12 +33,12 @@ void reportProgress(const char *line) { // Run a Python command and capture stdout. Stderr is scanned for PROGRESS lines and otherwise passed // through to the parent's stderr. Returns stdout content. If abort is signaled, kills the child process. -std::string runPython(const std::vector &args, std::atomic *abort = nullptr) { - // Build argv for the downloader module +std::string runPython(const char *module, const std::vector &args, std::atomic *abort = nullptr) { + // Build argv for the Python module std::vector argv; argv.push_back("python3"); argv.push_back("-m"); - argv.push_back("openpilot.tools.lib.file_downloader"); + argv.push_back(module); for (const auto &a : args) { argv.push_back(a.c_str()); } @@ -215,19 +218,23 @@ std::string download(const std::string &url, bool use_cache, std::atomic * if (!use_cache) { args.push_back("--no-cache"); } - return runPython(args, abort); + return runPython(DOWNLOADER_MODULE, args, abort); } std::string decompress(const std::string &path, std::atomic *abort) { - return runPython({"decompress", path}, abort); + return runPython(DOWNLOADER_MODULE, {"decompress", path}, abort); } std::string getRouteFiles(const std::string &route) { - return runPython({"route-files", route}); + return runPython(DOWNLOADER_MODULE, {"route-files", route}); +} + +std::string authenticate(const std::string &provider, std::atomic *abort) { + return runPython(AUTH_MODULE, {provider, "--json"}, abort); } std::string getDevices() { - return runPython({"devices"}); + return runPython(DOWNLOADER_MODULE, {"devices"}); } std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms, int64_t end_ms, bool preserved) { @@ -244,7 +251,7 @@ std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms, int6 args.push_back(std::to_string(end_ms)); } } - return runPython(args); + return runPython(DOWNLOADER_MODULE, args); } } // namespace PyDownloader diff --git a/openpilot/tools/replay/py_downloader.h b/openpilot/tools/replay/py_downloader.h index 80fab6ab00..fac66dabd5 100644 --- a/openpilot/tools/replay/py_downloader.h +++ b/openpilot/tools/replay/py_downloader.h @@ -18,6 +18,9 @@ std::string decompress(const std::string &path, std::atomic *abort = nullp // Returns JSON string of route files (same format as /v1/route/.../files API) std::string getRouteFiles(const std::string &route); +// Browser sign-in; abort closes the local callback server. Returns a JSON status. +std::string authenticate(const std::string &provider, std::atomic *abort); + // Returns JSON string of user's devices std::string getDevices(); From 11093e743e6e8cbefdc0e7db6f04c5880271c65a Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:44:13 -0700 Subject: [PATCH 097/270] cabana: improve dark slider track contrast (#38899) --- openpilot/tools/cabana/ui/theme.cc | 2 ++ openpilot/tools/cabana/ui/theme.h | 1 + openpilot/tools/cabana/ui/util.cc | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc index 696a933b82..14d0949835 100644 --- a/openpilot/tools/cabana/ui/theme.cc +++ b/openpilot/tools/cabana/ui/theme.cc @@ -19,6 +19,7 @@ constexpr Palette DARK_PALETTE = { .header = rgb(0x2f65ca), .header_hovered = rgb(0x414e65), .header_active = rgb(0x2f65ca), .accent = rgb(0x2f65ca), .border = rgb(0x282828), .separator = rgb(0x353535), .scrollbar_grab = rgb(0x484b4d), + .slider_track = rgb(0x777777), .tab = rgb(0x353535), .tab_hovered = rgb(0x484b4d), .table_header = rgb(0x484b4d), .grid = rgb(0xbbbbbb, 50.0f / 255.0f), .badge = rgb(0x808080), .bit_background = rgb(0xffffff, 20.0f / 255.0f), @@ -34,6 +35,7 @@ constexpr Palette LIGHT_PALETTE = { .header = rgb(0x308cc6), .header_hovered = rgb(0xe7f3fb), .header_active = rgb(0x308cc6), .accent = rgb(0x308cc6), .border = rgb(0xb6b6b6), .separator = rgb(0xd0d0d0), .scrollbar_grab = rgb(0xb6b6b6), + .slider_track = rgb(0xd0d0d0), .tab = rgb(0xe5e5e5), .tab_hovered = rgb(0xefefef), .table_header = rgb(0xefefef), .grid = rgb(0x000000, 50.0f / 255.0f), .badge = rgb(0xa0a0a4), .bit_background = rgb(0xffffff, 0.0f), diff --git a/openpilot/tools/cabana/ui/theme.h b/openpilot/tools/cabana/ui/theme.h index 020b137c62..9c8653cfe9 100644 --- a/openpilot/tools/cabana/ui/theme.h +++ b/openpilot/tools/cabana/ui/theme.h @@ -14,6 +14,7 @@ struct Palette { ImVec4 header, header_hovered, header_active; // selections ImVec4 accent; ImVec4 border, separator, scrollbar_grab; + ImVec4 slider_track; ImVec4 tab, tab_hovered, table_header; ImVec4 grid; ImVec4 badge; // the fill behind the time labels drawn over a chart diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 64e1b914be..1b7a2a7837 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -656,7 +656,7 @@ bool fusionSliderInt(const char *label, int *v, int min, int max, float width) { const float hx = x0 + (x1 - x0) * t; ImDrawList *dl = ImGui::GetWindowDrawList(); const float groove_y0 = cy - groove_h * 0.5f, groove_y1 = cy + groove_h * 0.5f; - dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(bb_max.x, groove_y1), u32(palette().separator), groove_h * 0.5f); + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(bb_max.x, groove_y1), u32(palette().slider_track), groove_h * 0.5f); dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(hx, groove_y1), u32(palette().accent), groove_h * 0.5f); drawSliderHandle(dl, ImRect(ImVec2(hx - SLIDER_LENGTH * 0.5f, cy - handle_h * 0.5f), ImVec2(hx + SLIDER_LENGTH * 0.5f, cy + handle_h * 0.5f))); From b6918cb31b298235203fce67b92fea60a9548adc Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:22:13 -0700 Subject: [PATCH 098/270] cabana: fix download bar layout (#38900) * cabana: fix download bar layout * cabana: reduce download label font size --- openpilot/tools/cabana/ui/mainwin.cc | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 924c37c0b5..48239602bf 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -718,6 +718,10 @@ void MainWindow::drawStatusBar() { // WindowPadding.x, which lines the text up with the content of the docked panels above (the messages table). const float width = ImGui::GetContentRegionAvail().x; const float pad = ImGui::GetStyle().WindowPadding.x; + const float progress_width = std::min(300.0f, std::max(0.0f, width - 2 * pad)); + const float progress_x = width - pad - progress_width; + const float message_end = status_bar_.progress_visible ? progress_x - ImGui::GetStyle().ItemSpacing.x : width - pad; + ImGui::PushClipRect(min, ImVec2(min.x + std::max(pad, message_end), min.y + ImGui::GetWindowHeight()), true); ImGui::SetCursorPosX(pad); ImGui::AlignTextToFramePadding(); // a temporary message hides the normal widgets, permanent widgets stay on the right @@ -728,9 +732,20 @@ void MainWindow::drawStatusBar() { bar.message.clear(); ImGui::TextUnformatted("For help, press F1"); } - if (bar.progress_visible) { - ImGui::SameLine(width - pad - 300.0f); - ImGui::ProgressBar(bar.progress_value, ImVec2(300.0f, 16.0f), bar.progress_text.c_str()); + ImGui::PopClipRect(); + if (bar.progress_visible && progress_width > 0) { + const float progress_height = 16.0f; + ImGui::PushFont(ImGui::GetFont(), 12.0f); + const std::string percentage = std::to_string((int)(bar.progress_value * 100)) + "%"; + const char *label = bar.progress_text.c_str(); + const float text_width = progress_width - 2 * ImGui::GetStyle().FramePadding.x; + if (ImGui::CalcTextSize(label).x > text_width) label = percentage.c_str(); + if (ImGui::CalcTextSize(label).x > text_width) label = ""; + ImGui::SameLine(progress_x); + ImGui::SetCursorPosY((ImGui::GetWindowHeight() - progress_height) / 2.0f); + ImGui::ProgressBar(bar.progress_value, ImVec2(progress_width, progress_height), label); + ImGui::PopFont(); + ImGui::SetItemTooltip("%s", bar.progress_text.c_str()); } ImGui::EndChild(); ImGui::PopStyleColor(); From 94f71fce00cf905f019c00acf5454bf0a2010a92 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:23:22 -0700 Subject: [PATCH 099/270] cabana: add browser sign-in for remote routes (#38894) * cabana: add browser sign-in for remote routes * cabana: use shared dialog styling for browser sign-in * cabana: support spacious centered action buttons * cabana: vertically center sign-in states * cabana: keep sign-in layout stable between states * cabana: center sign-in error text * cabana: avoid hint flash when retrying sign-in --- .../tools/cabana/ui/dialogs/routesdialog.cc | 156 +++++++++++++++++- .../tools/cabana/ui/dialogs/routesdialog.h | 10 ++ .../tools/cabana/ui/dialogs/streamselector.cc | 2 + openpilot/tools/cabana/ui/util.cc | 30 ++-- openpilot/tools/cabana/ui/util.h | 12 +- 5 files changed, 195 insertions(+), 15 deletions(-) diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc index f0a5eb0b9e..e669ac5fd5 100644 --- a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -1,6 +1,13 @@ #include "tools/cabana/ui/dialogs/routesdialog.h" +#include +#include #include +#include + +#include "json11/json11.hpp" +#include "tools/replay/py_downloader.h" +#include "tools/cabana/ui/theme.h" #include "imgui.h" #include "imgui_internal.h" @@ -14,12 +21,17 @@ const int PERIOD_DAYS[] = {7, 14, 30, 180, -1}; } // namespace void RoutesDialog::open(std::function on_done) { + if (alive_) return; on_done_ = std::move(on_done); - open_ = true; + open_ = false; popup_.reset(); s_ = State{}; alive_ = std::make_shared(true); + fetchDevices(); +} + +void RoutesDialog::fetchDevices() { routes::fetchDevices([this, alive = std::weak_ptr(alive_)](std::vector devices, bool success, int error_code) { utils::runOnMainThread(utils::guarded(alive.lock(), [this, devices = std::move(devices), success, error_code]() { setDeviceList(devices, success, error_code); @@ -29,14 +41,19 @@ void RoutesDialog::open(std::function on_done) void RoutesDialog::setDeviceList(const std::vector &devices, bool success, int error_code) { if (success) { + s_.login = false; + open_ = true; s_.devices.clear(); for (const auto &device : devices) s_.devices.push_back(device.dongle_id); s_.devices_loaded = true; s_.device_index = 0; fetchRoutes(); + } else if (error_code == 401) { + s_.login = true; + open_ = true; } else { - // the box shows on top of the dialog, which is rejected once the box is dismissed - MessageBox::warning("Error", error_code == 401 ? "Unauthorized. Authenticate with openpilot/tools/lib/auth.py" : "Network error", "", + // Initial failures are shown on the calling window without opening the route browser. + MessageBox::warning("Error", "Network error", "", utils::guarded(alive_, [this]() { finish(false); })); } } @@ -72,6 +89,8 @@ void RoutesDialog::setRouteList(const std::vector &list, bool } void RoutesDialog::finish(bool accepted) { + if (auth_abort_) *auth_abort_ = true; + auth_abort_.reset(); alive_.reset(); open_ = false; auto on_done = std::move(on_done_); @@ -82,6 +101,16 @@ void RoutesDialog::draw() { if (!open_) return; if (!beginDialog("Remote Routes", &popup_, ImVec2(480.0f, 420.0f))) return; + if (s_.login) { + drawLogin(); + if (open_) { + MessageBox::draw(); + if (!open_) ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + return; + } + ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Device"); ImGui::SameLine(); @@ -122,3 +151,124 @@ void RoutesDialog::draw() { ImGui::EndPopup(); if (accepted || rejected) finish(accepted); } + +void RoutesDialog::signIn(const std::string &provider) { + s_.provider = provider == "google" ? "Google" : provider == "apple" ? "Apple" : "GitHub"; + s_.auth_error.clear(); + auth_abort_ = std::make_shared>(false); + std::thread([this, alive = std::weak_ptr(alive_), abort = auth_abort_, provider]() { + const std::string result = PyDownloader::authenticate(provider, abort.get()); + utils::runOnMainThread(utils::guarded(alive.lock(), [this, abort, result]() { + if (*abort) return; + auth_abort_.reset(); + std::string error; + auto status = json11::Json::parse(result, error); + if (status["success"].bool_value()) { + s_.login = false; + fetchDevices(); + } else { + s_.auth_error = status["error"].string_value(); + if (s_.auth_error.empty()) s_.auth_error = "Could not start sign-in. Please try again."; + } + })); + }).detach(); +} + +void RoutesDialog::drawLogin() { + const auto &p = palette(); + const char *providers[] = {"Google", "Apple", "GitHub"}; + const char *methods[] = {"google", "apple", "github"}; + const char *icons[] = {"\xef\x8f\xb0", "\xef\x99\x9b", "\xef\x8f\xad"}; + IconTextButtonOptions button_options{.height = 44.0f, .rounding = 8.0f, .icon_gap = 16.0f, .center_content = true}; + float button_width = iconTextButtonWidth("", "Choose another method", button_options); + for (int i = 0; i < 3; ++i) { + const std::string label = std::string("Sign in with ") + providers[i]; + button_width = std::max(button_width, iconTextButtonWidth(icons[i], label, button_options)); + button_options.label_width = std::max(button_options.label_width, ImGui::CalcTextSize(label.c_str()).x); + } + button_width += ImGui::GetStyle().FramePadding.x * 4; + // Keep the footer anchored while longer errors scroll inside the content area. + const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; + ImGui::BeginChild("login_content", ImVec2(0, -footer)); + ImGui::Indent(16); + const float gap = ImGui::GetStyle().ItemSpacing.y; + const float wrap_x = ImGui::GetWindowWidth() - 28; + const float text_width = wrap_x - ImGui::GetCursorPosX(); + const char *chooser_intro = "Use your comma account to browse recorded drives and open a route for analysis."; + const auto waiting_intro = [](const std::string &provider) { + return "Sign in with " + provider + " in your browser, then return to Cabana to choose a device and route."; + }; + const std::string intro = auth_abort_ ? waiting_intro(s_.provider) : chooser_intro; + const char *hint = "Use the comma account paired with your device."; + // Reserve the same space in every state so the heading and controls never jump. + float intro_height = ImGui::CalcTextSize(chooser_intro, nullptr, false, text_width).y; + for (const char *provider : providers) { + intro_height = std::max(intro_height, ImGui::CalcTextSize(waiting_intro(provider).c_str(), nullptr, false, text_width).y); + } + const float controls_height = 3 * (button_options.height + 2 * gap) + + ImGui::CalcTextSize(hint, nullptr, false, text_width).y; + const float content_height = 28 + 2 * gap + intro_height + 2 * gap + 12 + controls_height; + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + std::max(0.0f, (ImGui::GetContentRegionAvail().y - content_height) * 0.5f)); + ImGui::PushFont(boldFont(), 28.0f); + ImGui::TextUnformatted("Open your routes in Cabana"); + ImGui::PopFont(); + ImGui::Spacing(); + ImGui::PushTextWrapPos(wrap_x); + const float controls_y = ImGui::GetCursorPosY() + intro_height + 2 * gap + 12; + const float buttons_x = ImGui::GetCursorPosX() + (ImGui::GetContentRegionAvail().x - 16 - button_width) * 0.5f; + if (auth_abort_) { + ImGui::TextWrapped("%s", intro.c_str()); + ImGui::SetCursorPosY(controls_y); + ImGui::BeginChild("auth_status", ImVec2(-16, 76), ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground); + const char *status = "Waiting for browser sign-in"; + const char *timeout = "This request expires after 3 minutes."; + const float spinner_size = ImGui::GetFontSize(); + const float status_width = spinner_size + 8 + ImGui::CalcTextSize(status).x; + ImGui::SetCursorPos(ImVec2((ImGui::GetWindowWidth() - status_width) * 0.5f, 16)); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const float angle = std::fmod(ImGui::GetTime() * 4.0, 2.0 * IM_PI); + auto *draw_list = ImGui::GetWindowDrawList(); + draw_list->PathArcTo(ImVec2(pos.x + spinner_size * 0.5f, pos.y + spinner_size * 0.5f), + spinner_size * 0.35f, angle, angle + IM_PI * 1.5f, 24); + draw_list->PathStroke(ImGui::GetColorU32(p.accent), 0, 2.0f); + ImGui::Dummy(ImVec2(spinner_size, spinner_size)); + ImGui::SameLine(0, 8); + ImGui::TextUnformatted(status); + ImGui::SetCursorPos(ImVec2((ImGui::GetWindowWidth() - ImGui::CalcTextSize(timeout).x) * 0.5f, 40)); + ImGui::TextUnformatted(timeout); + ImGui::EndChild(); + ImGui::SetCursorPosY(controls_y + 2 * (button_options.height + 2 * gap)); + ImGui::SetCursorPosX(buttons_x); + if (iconTextButton("auth_retry", "", "Choose another method", button_width, button_options)) { + *auth_abort_ = true; + auth_abort_.reset(); + } + } else { + ImGui::TextWrapped("%s", intro.c_str()); + ImGui::SetCursorPosY(controls_y); + const char *selected_method = nullptr; + for (int i = 0; i < 3; ++i) { + ImGui::SetCursorPosX(buttons_x); + if (iconTextButton(methods[i], icons[i], std::string("Sign in with ") + providers[i], + button_width, button_options)) selected_method = methods[i]; + ImGui::Spacing(); + } + const char *message = s_.auth_error.empty() ? hint : s_.auth_error.c_str(); + const float width = ImGui::GetContentRegionAvail().x - 16; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (width - ImGui::CalcTextSize(message).x) * 0.5f)); + ImGui::TextWrapped("%s", message); + // Finish drawing this state before signIn clears its error text. + if (selected_method) signIn(selected_method); + } + ImGui::PopTextWrapPos(); + ImGui::Unindent(16); + ImGui::EndChild(); + ImGui::Separator(); + bool rejected = false; + dialogButtons("Cancel", &rejected, nullptr, true, nullptr); + if (rejected || dialogEscapePressed()) { + ImGui::CloseCurrentPopup(); + finish(false); + } +} diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.h b/openpilot/tools/cabana/ui/dialogs/routesdialog.h index 8f84096973..c0f6db791c 100644 --- a/openpilot/tools/cabana/ui/dialogs/routesdialog.h +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -11,10 +12,15 @@ // "Remote routes" browser. on_done gets accepted=true with the selected route name ("" if none), accepted=false on cancel. class RoutesDialog { public: + ~RoutesDialog() { if (auth_abort_) *auth_abort_ = true; } void open(std::function on_done); void draw(); + bool isActive() const { return alive_ != nullptr; } private: + void fetchDevices(); + void signIn(const std::string &provider); + void drawLogin(); void setDeviceList(const std::vector &devices, bool success, int error_code); void setRouteList(const std::vector &list, bool success); void fetchRoutes(); @@ -26,6 +32,9 @@ private: }; struct State { + bool login = false; + std::string provider; + std::string auth_error; bool devices_loaded = false; std::vector devices; int device_index = 0; @@ -36,6 +45,7 @@ private: int fetch_id = 0; // the reply of an older request is dropped }; + std::shared_ptr> auth_abort_; bool open_ = false; PopupOwner popup_; State s_; diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.cc b/openpilot/tools/cabana/ui/dialogs/streamselector.cc index 17cae9635c..d320216109 100644 --- a/openpilot/tools/cabana/ui/dialogs/streamselector.cc +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.cc @@ -22,11 +22,13 @@ void OpenReplayWidget::draw() { ImGui::GetStyle().ItemSpacing.x * 2)); inputText("##route", &route_, "Enter a route name or browse for a local or remote route"); ImGui::SameLine(); + ImGui::BeginDisabled(routes_dialog_.isActive()); if (ImGui::Button("Remote Route...")) { routes_dialog_.open(utils::guarded(alive_, [this](bool accepted, const std::string &route) { if (accepted) route_ = route; })); } + ImGui::EndDisabled(); ImGui::SameLine(); if (ImGui::Button("Local Route...")) { FileDialog::getExistingDirectory("Open Local Route", settings.last_route_dir, utils::guarded(alive_, [this](const std::string &dir) { diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index 1b7a2a7837..b11ecf28fa 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -224,23 +224,33 @@ bool iconButton(const char *id, const char *icon, const char *tooltip) { return clicked; } -float iconTextButtonWidth(const char *icon, const std::string &text) { +float iconTextButtonWidth(const char *icon, const std::string &text, const IconTextButtonOptions &options) { const ImGuiStyle &style = ImGui::GetStyle(); - return ImGui::CalcTextSize(icon).x + style.ItemInnerSpacing.x + ImGui::CalcTextSize(text.c_str(), nullptr, true).x + style.FramePadding.x * 2; + const float gap = *icon ? (options.icon_gap >= 0.0f ? options.icon_gap : style.ItemInnerSpacing.x) : 0.0f; + return ImGui::CalcTextSize(icon).x + gap + std::max(ImGui::CalcTextSize(text.c_str(), nullptr, true).x, options.label_width) + + style.FramePadding.x * 2; } -bool iconTextButton(const char *id, const char *icon, const std::string &text, float width) { +bool iconTextButton(const char *id, const char *icon, const std::string &text, float width, const IconTextButtonOptions &options) { const ImGuiStyle &style = ImGui::GetStyle(); - if (width <= 0.0f) width = iconTextButtonWidth(icon, text); - const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(width, 0.0f)); + const float icon_width = ImGui::CalcTextSize(icon).x; + const float gap = *icon ? (options.icon_gap >= 0.0f ? options.icon_gap : style.ItemInnerSpacing.x) : 0.0f; + const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; + const float label_width = std::max(text_width, options.label_width); + if (width <= 0.0f) width = icon_width + gap + label_width + style.FramePadding.x * 2; + if (options.rounding >= 0.0f) ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, options.rounding); + const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(width, options.height)); + if (options.rounding >= 0.0f) ImGui::PopStyleVar(); const ImVec2 min = ImGui::GetItemRectMin(), max = ImGui::GetItemRectMax(); + const float top = min.y + (max.y - min.y - ImGui::GetFontSize()) * 0.5f; + const float left = min.x + (options.center_content ? std::max(style.FramePadding.x, (max.x - min.x - icon_width - gap - label_width) * 0.5f) + : style.FramePadding.x); + const float text_left = left + icon_width + gap; + const float slack = options.center_content ? 0.0f : std::max(0.0f, (max.x - style.FramePadding.x - text_left - text_width) * 0.5f); const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); auto *draw_list = ImGui::GetWindowDrawList(); - draw_list->AddText(ImVec2(min.x + style.FramePadding.x, min.y + style.FramePadding.y), color, icon); - // the text is centered between the icon and the right padding - const float left = min.x + style.FramePadding.x + ImGui::CalcTextSize(icon).x + style.ItemInnerSpacing.x; - const float slack = max.x - style.FramePadding.x - left - ImGui::CalcTextSize(text.c_str(), nullptr, true).x; - draw_list->AddText(ImVec2(left + std::max(0.0f, slack * 0.5f), min.y + style.FramePadding.y), color, text.c_str()); + draw_list->AddText(ImVec2(left, top), color, icon); + draw_list->AddText(ImVec2(text_left + slack, top), color, text.c_str()); return clicked; } diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h index 3f3cabb71a..d8f11700a7 100644 --- a/openpilot/tools/cabana/ui/util.h +++ b/openpilot/tools/cabana/ui/util.h @@ -84,8 +84,16 @@ inline std::string shortcut(const char *keys) { return std::string(MOD_KEY) + "+ bool iconButton(const char *id, const char *icon, const char *tooltip = nullptr); float iconButtonWidth(); bool stepButton(const char *id, bool increment, const char *tooltip = nullptr); -bool iconTextButton(const char *id, const char *icon, const std::string &text, float width = 0.0f); -float iconTextButtonWidth(const char *icon, const std::string &text); +struct IconTextButtonOptions { + float height = 0.0f; + float rounding = -1.0f; // Negative uses the theme default. + float icon_gap = -1.0f; // Negative uses the theme default. + bool center_content = false; + float label_width = 0.0f; // Shared width aligns labels in a group of centered buttons. +}; +bool iconTextButton(const char *id, const char *icon, const std::string &text, float width = 0.0f, + const IconTextButtonOptions &options = {}); +float iconTextButtonWidth(const char *icon, const std::string &text, const IconTextButtonOptions &options = {}); // tooltip for the last item that also shows while the item is disabled void disabledItemTooltip(const char *text); From e6f9b3c6c49bf56dab9688ae846a718d031109af Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:46:14 -0700 Subject: [PATCH 100/270] cabana: preserve menu bar border on highlight (#38902) * cabana: preserve menu bar border on highlight * cabana: remove border drawing comment --- openpilot/tools/cabana/ui/mainwin.cc | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 48239602bf..aa33e8adc2 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -138,11 +138,6 @@ void MainWindow::drawMenuBar() { const bool open = ImGui::BeginMainMenuBar(); ImGui::PopStyleVar(); if (!open) return; - { - const ImVec2 min = ImGui::GetWindowPos(); - const ImVec2 max(min.x + ImGui::GetWindowWidth(), min.y + ImGui::GetWindowHeight()); - ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(min.x, max.y - 1.0f), max, ImGui::GetColorU32(ImGuiCol_Border)); - } if (dropdown::BeginMenu("File")) { drawFileMenu(); dropdown::EndMenu(); @@ -181,6 +176,9 @@ void MainWindow::drawMenuBar() { if (dropdown::Item("Help", "F1")) toggleHelp(); dropdown::EndMenu(); } + const ImVec2 min = ImGui::GetWindowPos(); + const ImVec2 max(min.x + ImGui::GetWindowWidth(), min.y + ImGui::GetWindowHeight()); + ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(min.x, max.y - 1.0f), max, ImGui::GetColorU32(ImGuiCol_Border)); ImGui::EndMainMenuBar(); } From 5c365d3398e92acac97ddaf93e2dfa054ddf87d9 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:46:29 -0700 Subject: [PATCH 101/270] cabana: remove inner video container (#38901) --- openpilot/tools/cabana/ui/mainwin.cc | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index aa33e8adc2..545d846b12 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -863,10 +863,8 @@ void MainWindow::drawVideoPanel() { if (video_widget_ && !video_open) { video_widget_->setVisible(false); // the dock is collapsed or tabbed behind another one, like hideEvent } else if (video_widget_) { - ImGui::BeginChild("video", ImVec2(0, 0), ImGuiChildFlags_Borders); help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); video_widget_->draw(); - ImGui::EndChild(); } ImGui::End(); if (!video_visible_ && floating) video_visible_ = reset_layout_ = true; From 4e60afe70d7f76b11e3270280bef628aa13e135a Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:48:35 -0700 Subject: [PATCH 102/270] cabana: show persistent fps counter (#38898) * cabana: show persistent UI FPS * cabana: render whole fps in monospace * cabana: keep fps aligned beside centered progress * cabana: move fps to top right * Revert "cabana: move fps to top right" This reverts commit 51a5334aec79807f8102b9a6f1f902f22814d5c7. --- openpilot/tools/cabana/ui/mainwin.cc | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 545d846b12..2e42b79242 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -716,12 +716,16 @@ void MainWindow::drawStatusBar() { // WindowPadding.x, which lines the text up with the content of the docked panels above (the messages table). const float width = ImGui::GetContentRegionAvail().x; const float pad = ImGui::GetStyle().WindowPadding.x; - const float progress_width = std::min(300.0f, std::max(0.0f, width - 2 * pad)); - const float progress_x = width - pad - progress_width; - const float message_end = status_bar_.progress_visible ? progress_x - ImGui::GetStyle().ItemSpacing.x : width - pad; + pushMonoFont(ImGui::GetStyle().FontSizeBase); + const float fps_x = std::max(pad, width - pad - ImGui::CalcTextSize("999 FPS").x); + popMonoFont(); + const float progress_width = std::min(300.0f, std::max(0.0f, fps_x - pad - 20.0f)); + const float progress_x = fps_x - progress_width - 10.0f; + const float message_end = status_bar_.progress_visible ? progress_x - ImGui::GetStyle().ItemSpacing.x : fps_x - 10.0f; ImGui::PushClipRect(min, ImVec2(min.x + std::max(pad, message_end), min.y + ImGui::GetWindowHeight()), true); ImGui::SetCursorPosX(pad); ImGui::AlignTextToFramePadding(); + const float text_y = ImGui::GetCursorPosY(); // a temporary message hides the normal widgets, permanent widgets stay on the right auto &bar = status_bar_; if (!bar.message.empty() && (bar.message_until == 0 || ImGui::GetTime() < bar.message_until)) { @@ -745,6 +749,12 @@ void MainWindow::drawStatusBar() { ImGui::PopFont(); ImGui::SetItemTooltip("%s", bar.progress_text.c_str()); } + ImGui::SameLine(fps_x); + ImGui::SetCursorPosY(text_y); + pushMonoFont(ImGui::GetStyle().FontSizeBase); + ImGui::Text("%3.0f FPS", ImGui::GetIO().Framerate); + popMonoFont(); + ImGui::SetItemTooltip("UI rendering rate (frames per second)"); ImGui::EndChild(); ImGui::PopStyleColor(); } @@ -789,7 +799,7 @@ void MainWindow::drawDockspace() { // the status bar sits below the dockspace: reserve its height plus the item spacing between the two, // otherwise the host window is a few pixels taller than the viewport and scrolls - const float status_height = full_screen_ ? 0.0f : ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y; + const float status_height = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y; const ImVec2 dock_size(ImGui::GetContentRegionAvail().x, ImGui::GetContentRegionAvail().y - status_height); const ImGuiID dock_id = ImGui::GetID("cabana_dockspace"); if (reset_layout_ || ImGui::DockBuilderGetNode(dock_id) == nullptr || @@ -815,7 +825,7 @@ void MainWindow::drawDockspace() { ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(min_panel_width, ImGui::GetStyle().WindowMinSize.y)); ImGui::DockSpace(dock_id, dock_size); ImGui::PopStyleVar(); - if (!full_screen_) drawStatusBar(); + drawStatusBar(); ImGui::End(); } From 2ae3598b51829b4fbed16605739bae272c90e1b2 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:56:21 -0700 Subject: [PATCH 103/270] cabana: prevent signal panel collapse (#38903) * cabana: keep signal scrolling inside the list * cabana: reserve space for the signal panel --- openpilot/tools/cabana/ui/widgets/detailwidget.cc | 7 ++++--- openpilot/tools/cabana/ui/widgets/signalview.cc | 13 ++++++++++--- openpilot/tools/cabana/ui/widgets/signalview.h | 1 + 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 988cf4b3cb..8f42b63d80 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -218,16 +218,17 @@ void DetailWidget::drawTabWidget() { ImGui::BeginChild("page", ImVec2(0, std::max(page_rect.GetHeight() - pill_height - gap, 1.0f)), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); if (tab_widget_index_ == 0) { - // binary_view_ keeps its size hint, signal_view_ takes the rest + // Reserve the signal toolbar and rows before giving the byte grid its preferred height. const float min_height = binary_view_->minimumSizeHint().y; const float avail = ImGui::GetContentRegionAvail().y; - const float max_height = std::max(avail - style.ItemSpacing.y - 1.0f, 1.0f); + const float max_height = std::max(avail - style.ItemSpacing.y - SignalView::minimumHeight(), 1.0f); const float height = std::clamp(min_height, 1.0f, max_height); ImGui::BeginChild("binary_view", ImVec2(0, height), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); binary_view_->draw(); ImGui::EndChild(); - ImGui::BeginChild("signal_view", ImVec2(0, 0)); + ImGui::BeginChild("signal_view", ImVec2(0, 0), ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); signal_view_rect_ = ImGui::GetCurrentWindow()->Rect(); signal_view_->draw(); ImGui::EndChild(); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index 269df6158c..48a74fd111 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -690,9 +690,16 @@ float SignalView::minimumWidth() { return left_width + style.ItemSpacing.x + toolBarRightWidth("00:00") + (style.WindowPadding.x + style.ChildBorderSize) * 2; } +float SignalView::minimumHeight() { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::GetFrameHeight() + style.ItemSpacing.y + signalRowHeight() * 3 + + (style.WindowPadding.y + style.ChildBorderSize + CONTROL_OUTLINE_PADDING) * 2; +} + void SignalView::draw() { ImGui::PushStyleColor(ImGuiCol_ChildBg, palette().surface); - if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_Borders)) { + if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_Borders, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { ImGui::EndChild(); ImGui::PopStyleColor(); return; @@ -740,8 +747,8 @@ void SignalView::collapseAll() { void SignalView::drawTree() { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); - const float min_height = std::max(ImGui::GetContentRegionAvail().y, 300.0f); - const bool visible = beginControlChild("tree", ImVec2(0, min_height)); + // Keep the toolbar fixed; only the signal rows scroll within the remaining space. + const bool visible = beginControlChild("tree", ImVec2(0, 0)); if (visible) { DrawContext ctx{ImGui::GetWindowDrawList(), ImGui::GetCursorScreenPos().x, ImGui::GetContentRegionAvail().x, rowHeight()}; // the press that closes an open editor is consumed by the focus change, the index widgets never see it diff --git a/openpilot/tools/cabana/ui/widgets/signalview.h b/openpilot/tools/cabana/ui/widgets/signalview.h index dc77ad7b37..d6ba246097 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.h +++ b/openpilot/tools/cabana/ui/widgets/signalview.h @@ -110,6 +110,7 @@ public: void setMessage(const MessageId &id); void draw(); static float minimumWidth(); + static float minimumHeight(); void signalHovered(const cabana::Signal *sig); // handler for BinaryView::signalHovered void updateChartState(); void selectSignal(const cabana::Signal *sig, bool expand = false); From c715ef47627d1d7ae69577641667e25aeeb4317c Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:15:25 -0700 Subject: [PATCH 104/270] cabana: flatten signal panel containers (#38904) * cabana: flatten signal panel containers * cabana: keep messages and logs switch border --- openpilot/tools/cabana/ui/widgets/detailwidget.cc | 2 +- openpilot/tools/cabana/ui/widgets/signalview.cc | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 8f42b63d80..6bfcccf144 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -281,7 +281,7 @@ void DetailWidget::drawTabWidget() { void DetailWidget::draw() { tabbar_.draw(); - ImGui::BeginChild("message_content", ImVec2(0, 0), ImGuiChildFlags_Borders, + ImGui::BeginChild("message_content", ImVec2(0, 0), ImGuiChildFlags_AlwaysUseWindowPadding, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); drawToolBar(); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index 48a74fd111..c8ed72f956 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -682,12 +682,12 @@ float SignalView::toolBarRightWidth(const std::string &range_label) { } // the width at which the tool bar stops squishing: the signal count and the filter box on the left, the -// sparkline controls on the right, plus the borders and padding of the view's own child window +// sparkline controls on the right. Padding is supplied by the message panel. float SignalView::minimumWidth() { const ImGuiStyle &style = ImGui::GetStyle(); const float left_width = ImGui::CalcTextSize("Signals: 000").x + style.ItemSpacing.x + FILTER_WIDTH; // formatSeconds is mm:ss for every value the range slider allows - return left_width + style.ItemSpacing.x + toolBarRightWidth("00:00") + (style.WindowPadding.x + style.ChildBorderSize) * 2; + return left_width + style.ItemSpacing.x + toolBarRightWidth("00:00") + style.WindowPadding.x * 2; } float SignalView::minimumHeight() { @@ -698,7 +698,7 @@ float SignalView::minimumHeight() { void SignalView::draw() { ImGui::PushStyleColor(ImGuiCol_ChildBg, palette().surface); - if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_Borders, + if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { ImGui::EndChild(); ImGui::PopStyleColor(); @@ -885,6 +885,8 @@ void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { const ImVec2 size = indexButtonsSize(iconButtonWidth()); ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - size.x, rect.Min.y + (rect.GetHeight() - size.y) * 0.5f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); const auto sig = item->sig; const bool checked = item->chart_opened; if (checked) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); @@ -899,6 +901,8 @@ void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { pending_action_ = [this, sig]() { UndoStack::instance()->push(new RemoveSigCommand(model_.msgId(), sig)); }; } ImGui::SetItemTooltip("Remove signal"); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); button_size_ = size; } From 164a3a1e307c8efcd8ad03f1533d82732af410df Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:27:40 -0700 Subject: [PATCH 105/270] cabana: stop chart tooltips over menus (#38905) * cabana: keep chart tooltips below menus * cabana: exclude popup menus from plot hover --- openpilot/tools/cabana/ui/chart/chart.cc | 13 +++++++------ openpilot/tools/cabana/ui/chart/chartswidget.cc | 2 +- openpilot/tools/cabana/ui/chart/tiplabel.cc | 2 +- openpilot/tools/cabana/ui/chart/tiplabel.h | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 4f1d7891ea..1c319b4faf 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -504,13 +504,13 @@ void ChartView::draw(float width) { updateLayout(); paint(); drawContextMenu(); + // Keep the tip above the plot, but below popup menus and other windows. + ImRect visible_rect = charts_widget_->chartVisibleRect(this); + visible_rect.ClipWith(ImRect(ImVec2(layout_.rect.Min.x, layout_.plot_area.Min.y), + ImVec2(layout_.rect.Max.x, layout_.plot_area.Max.y))); + if (!drawing_ghost_ && visible_rect.GetWidth() > 0 && visible_rect.GetHeight() > 0) tip_label_.draw(visible_rect); } ImGui::EndChild(); - // a chart scrolled out of the viewport draws no tip - ImRect visible_rect = charts_widget_->chartVisibleRect(this); - visible_rect.ClipWith(ImRect(ImVec2(layout_.rect.Min.x, layout_.plot_area.Min.y), - ImVec2(layout_.rect.Max.x, layout_.plot_area.Max.y))); - if (!drawing_ghost_ && visible_rect.GetWidth() > 0 && visible_rect.GetHeight() > 0) tip_label_.draw(visible_rect); ImGui::PopID(); } @@ -575,7 +575,8 @@ void ChartView::drawAxes() { layout_.plot_area = ImRect(ImPlot::GetPlotPos(), ImPlot::GetPlotPos() + ImPlot::GetPlotSize()); // ImPlotFlags_NoInputs disables implot's own hover tracking - layout_.plot_hovered = layout_.plot_area.Contains(ImGui::GetMousePos()) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + // A popup is a descendant of the chart, but hovering its menu must not hover the plot underneath. + layout_.plot_hovered = layout_.plot_area.Contains(ImGui::GetMousePos()) && ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); drawSeries(); if (!drawing_ghost_) { // Own plot clicks so custom scrubbing/zooming cannot also move the floating window. diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc index 12bf656a68..c89dc702db 100644 --- a/openpilot/tools/cabana/ui/chart/chartswidget.cc +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -551,7 +551,7 @@ void ChartsWidget::handleEvents() { if (!value_tip_visible_) return; - // the tip is drawn on the foreground draw list, so the mouse is never "on the tip" + // The tip is drawn without an input item, so the mouse is never "on the tip". const ImVec2 delta = ImGui::GetIO().MouseDelta; if (!any_plot_hovered_ && (delta.x != 0 || delta.y != 0 || !ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows))) { diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.cc b/openpilot/tools/cabana/ui/chart/tiplabel.cc index 6f3a1ee54e..83c693babf 100644 --- a/openpilot/tools/cabana/ui/chart/tiplabel.cc +++ b/openpilot/tools/cabana/ui/chart/tiplabel.cc @@ -134,7 +134,7 @@ void TipLabel::draw(const ImRect &rect) { updateLayout(); if (!visible_) return; - ImDrawList *p = ImGui::GetForegroundDrawList(); + ImDrawList *p = ImGui::GetWindowDrawList(); p->PushClipRect(area_.Min, area_.Max, true); // filled panel with a 1px frame p->AddRectFilled(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_PopupBg), ImGui::GetStyle().PopupRounding); diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.h b/openpilot/tools/cabana/ui/chart/tiplabel.h index 48f20b59f8..7128a56384 100644 --- a/openpilot/tools/cabana/ui/chart/tiplabel.h +++ b/openpilot/tools/cabana/ui/chart/tiplabel.h @@ -20,7 +20,7 @@ public: void showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect); void hide() { visible_ = false; } bool isVisible() const { return visible_; } - void draw(const ImRect &rect); // draws the tip on the foreground draw list; call once per frame + void draw(const ImRect &rect); // call inside the owning chart window, after drawing the plot private: // lays the lines out from origin, drawing them when p is given; returns the size of the text block From 5319474ee57abeaafcd787a69e66823c873e1f93 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:31:19 -0700 Subject: [PATCH 106/270] cabana: disable tool window collapse (#38906) --- openpilot/tools/cabana/ui/tools/tooldialog.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/tools/cabana/ui/tools/tooldialog.h b/openpilot/tools/cabana/ui/tools/tooldialog.h index 06807bcd33..4a8aeab7b2 100644 --- a/openpilot/tools/cabana/ui/tools/tooldialog.h +++ b/openpilot/tools/cabana/ui/tools/tooldialog.h @@ -28,7 +28,7 @@ protected: ImGui::SetNextWindowSize(size, ImGuiCond_Appearing); setNextWindowFloatsOut(); began_ = true; - return visible_ = ImGui::Begin(title_.c_str(), &open_, ImGuiWindowFlags_NoSavedSettings); + return visible_ = ImGui::Begin(title_.c_str(), &open_, ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoCollapse); } bool end() { From 885792de3fbcf0eff5c558dd4070cb4fb0f135fa Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:36:16 -0700 Subject: [PATCH 107/270] cabana: remove message table outer border (#38907) --- openpilot/tools/cabana/ui/widgets/messageswidget.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc index a12942aa83..a3cacea680 100644 --- a/openpilot/tools/cabana/ui/widgets/messageswidget.cc +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -365,8 +365,8 @@ void MessagesWidget::drawTable() { const bool multiple_lines = settings.multiple_lines_hex; const ImGuiTableFlags flags = ImGuiTableFlags_Sortable | ImGuiTableFlags_Resizable | ImGuiTableFlags_Reorderable | - ImGuiTableFlags_ScrollX | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Borders | - ImGuiTableFlags_Hideable; + ImGuiTableFlags_ScrollX | ImGuiTableFlags_ScrollY | ImGuiTableFlags_BordersInner | + ImGuiTableFlags_Hideable | ImGuiTableFlags_PadOuterX; // with ScrollX a stretch column needs an explicit inner width const float bytes_width = bytesCellSize(bytes_section_bytes_, multiple_lines).x; const float avail_width = ImGui::GetContentRegionAvail().x - (has_scrollbar_y_ ? ImGui::GetStyle().ScrollbarSize : 0); From d3106c2b4b8a0f168496837eab88f95fac74cc87 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:43:53 -0700 Subject: [PATCH 108/270] cabana: unify video and live stream titles (#38908) * cabana: unify video and live stream titles * cabana: put stream before dbc in window title * cabana: show fingerprint in route details --- openpilot/tools/cabana/ui/mainwin.cc | 13 ++++++++----- openpilot/tools/cabana/ui/mainwin.h | 2 +- openpilot/tools/cabana/ui/tools/routeinfo.cc | 6 ++++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 2e42b79242..674f2c3aa1 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -156,7 +156,7 @@ void MainWindow::drawMenuBar() { if (dropdown::Item("Full Screen", shortcut("F11").c_str())) toggleFullScreen(); ImGui::Separator(); dropdown::Item(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); - dropdown::Item(video_dock_title_.empty() ? "Video" : video_dock_title_.c_str(), nullptr, &video_visible_); + dropdown::Item(videoPanelTitle(), nullptr, &video_visible_); dropdown::Item("Charts", nullptr, &charts_visible_); ImGui::Separator(); if (dropdown::Item("Reset Window Layout")) { @@ -199,11 +199,15 @@ void MainWindow::showStatusMessage(const std::string &msg, int timeout_ms) { void MainWindow::updateWindowTitle() { std::string title; - for (auto f : dbc()->allDBCFiles()) { + for (auto f : dbc()->nonEmptyDBCFiles()) { if (!title.empty()) title += " | "; title += "(" + toString(dbc()->sources(f)) + ") " + f->name(); } if (window_modified_) title += "*"; + if (hasStream()) { + const std::string stream_title = can->liveStreaming() ? videoPanelTitle() : can->routeName(); + title = title.empty() ? stream_title : stream_title + " \xe2\x80\x94 " + title; + } if (!title.empty()) title += " \xe2\x80\x94 "; // em dash separator title += "Cabana"; glfwSetWindowTitle(window_, title.c_str()); @@ -362,12 +366,12 @@ void MainWindow::startStream(std::unique_ptr stream, const std:: MessageBox::warning("Error", msg); })); can->start(); + updateWindowTitle(); loadFile(dbc_file, SOURCE_ALL, [this]() { showStatusMessage("Stream [" + can->routeName() + "] started", 2000); createDockWidgets(); - video_dock_title_ = can->routeName(); // Don't overwrite already loaded DBC if (!dbc()->nonEmptyDBCCount()) { newFile(); @@ -391,7 +395,6 @@ void MainWindow::startStream(std::unique_ptr stream, const std:: void MainWindow::eventsMerged() { const std::string fingerprint = can->carFingerprint(); if (!can->liveStreaming() && std::exchange(car_fingerprint_, fingerprint) != fingerprint) { - video_dock_title_ = "ROUTE: " + can->routeName() + " FINGERPRINT: " + (car_fingerprint_.empty() ? "Unknown Car" : car_fingerprint_); // Don't overwrite already loaded DBC auto it = fingerprint_to_dbc_.find(car_fingerprint_); if (!dbc()->nonEmptyDBCCount() && it != fingerprint_to_dbc_.end()) { @@ -866,7 +869,7 @@ void MainWindow::drawMessagesPanel() { } void MainWindow::drawVideoPanel() { - const std::string name = (video_dock_title_.empty() ? "Video" : video_dock_title_) + VIDEO_PANEL; + const std::string name = std::string(videoPanelTitle()) + VIDEO_PANEL; setNextPanelClass(); const bool video_open = beginPanel(name.c_str(), &video_visible_); const bool floating = floatingOut(); diff --git a/openpilot/tools/cabana/ui/mainwin.h b/openpilot/tools/cabana/ui/mainwin.h index 840ca0990a..b7afa1769d 100644 --- a/openpilot/tools/cabana/ui/mainwin.h +++ b/openpilot/tools/cabana/ui/mainwin.h @@ -45,6 +45,7 @@ public: private: bool hasStream() const { return dynamic_cast(can) == nullptr; } + const char *videoPanelTitle() const { return hasStream() && can->liveStreaming() ? "Live Stream" : "Video"; } void releaseStream(); void startStream(std::unique_ptr stream, const std::string &dbc_file); void loadStartupStream(const std::string &dbc_file); @@ -101,7 +102,6 @@ private: std::vector opendbc_names_; enum { MAX_RECENT_FILES = 15 }; std::string car_fingerprint_; - std::string video_dock_title_; bool messages_visible_ = true; bool video_visible_ = true; bool charts_visible_ = true; diff --git a/openpilot/tools/cabana/ui/tools/routeinfo.cc b/openpilot/tools/cabana/ui/tools/routeinfo.cc index 1761da2a1f..92b77cf2a8 100644 --- a/openpilot/tools/cabana/ui/tools/routeinfo.cc +++ b/openpilot/tools/cabana/ui/tools/routeinfo.cc @@ -16,10 +16,12 @@ bool RouteInfoDlg::draw() { static const char *headers[] = {"", "rlog", "narrow road", "wide road", "driver", "qlog", "qcam"}; auto yn = [](const std::string &s) { return s.empty() ? "--" : "Yes"; }; const auto &segments = replay_->route().segments(); - // minimum size: header + min(rowCount, 13) rows + // minimum size: fingerprint, header, and min(rowCount, 13) rows float row_h = ImGui::GetTextLineHeightWithSpacing(); - float min_h = row_h * (std::min((int)segments.size(), 13) + 1) + ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().WindowPadding.y * 2; + float min_h = row_h * (std::min((int)segments.size(), 13) + 2) + ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().WindowPadding.y * 2; if (begin(ImVec2(520, min_h))) { + const std::string fingerprint = replay_->carFingerprint(); + ImGui::TextWrapped("Fingerprint: %s", fingerprint.empty() ? "Unknown" : fingerprint.c_str()); const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingFixedFit; if (ImGui::BeginTable("table", 7, flags, ImVec2(0, 0))) { ImGui::TableSetupScrollFreeze(0, 1); From 64e5d1e5e387cb35b8419ad25e53f6dbe687969a Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:54:34 -0700 Subject: [PATCH 109/270] cabana: preserve selected menu color on hover (#38909) * cabana: unify menu highlight colors * cabana: preserve selected menu color on hover --- openpilot/tools/cabana/ui/dropdown.h | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openpilot/tools/cabana/ui/dropdown.h b/openpilot/tools/cabana/ui/dropdown.h index b73d8e39a2..e6ce25f16b 100644 --- a/openpilot/tools/cabana/ui/dropdown.h +++ b/openpilot/tools/cabana/ui/dropdown.h @@ -86,7 +86,12 @@ inline void EndPopup() { inline bool BeginMenu(const char *label, bool enabled = true) { PopupViewportScope viewport_scope; pushStyle(); - return finishBegin(ImGui::BeginMenu(label, enabled)); + // An open menu keeps its selection color while the pointer is over its label. + const bool selected = ImGui::IsPopupOpen(label); + if (selected) ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImGui::GetStyleColorVec4(ImGuiCol_Header)); + const bool open = ImGui::BeginMenu(label, enabled); + if (selected) ImGui::PopStyleColor(); + return finishBegin(open); } inline void EndMenu() { ImGui::PopStyleVar(); From 5b34f151de990e780ce8f30feb0a0b13b64b5bc7 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:01:04 -0700 Subject: [PATCH 110/270] cabana: balance signal action spacing (#38911) --- openpilot/tools/cabana/ui/widgets/signalview.cc | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index c8ed72f956..5b2eab8b43 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -495,7 +495,9 @@ void SignalView::drawValueDescriptionDlg() { } static ImVec2 indexButtonsSize(float button) { - return ImVec2(button * 2 + ImGui::GetStyle().ItemSpacing.x, button); + const auto &style = ImGui::GetStyle(); + // Include the value-to-button gap; the extra padding balances the inset icon glyphs. + return ImVec2(button * 2 + style.ItemInnerSpacing.x * 2 + style.FramePadding.x, button); } SignalView::SignalView(ChartsWidget *charts) : charts_(charts) { @@ -750,6 +752,7 @@ void SignalView::drawTree() { // Keep the toolbar fixed; only the signal rows scroll within the remaining space. const bool visible = beginControlChild("tree", ImVec2(0, 0)); if (visible) { + button_size_ = indexButtonsSize(iconButtonWidth()); DrawContext ctx{ImGui::GetWindowDrawList(), ImGui::GetCursorScreenPos().x, ImGui::GetContentRegionAvail().x, rowHeight()}; // the press that closes an open editor is consumed by the focus change, the index widgets never see it if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) editor_open_on_press_ = open_item_ != nullptr; @@ -881,9 +884,11 @@ bool SignalView::drawItem(SignalModel::Item *item, int depth, DrawContext &ctx) } void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { - const float spacing = ImGui::GetStyle().ItemSpacing.x; - const ImVec2 size = indexButtonsSize(iconButtonWidth()); - ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - size.x, rect.Min.y + (rect.GetHeight() - size.y) * 0.5f)); + const float spacing = ImGui::GetStyle().ItemInnerSpacing.x; + const float button = iconButtonWidth(); + // Anchor fixed-size buttons inside the viewport, which excludes the scrollbar. + ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - H_MARGIN - button * 2 - spacing, + rect.Min.y + (rect.GetHeight() - button) * 0.5f)); ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); @@ -903,7 +908,6 @@ void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { ImGui::SetItemTooltip("Remove signal"); ImGui::PopStyleColor(); ImGui::PopStyleVar(); - button_size_ = size; } ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions) { From 5f50bda7ba1a78862d0886c75f03125446bc6f1b Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:06:52 -0700 Subject: [PATCH 111/270] cabana: improve signal panel layout (#38910) * cabana: improve signal panel layout * cabana: match row actions to selection colors * cabana: preserve master remove action * cabana: match remove icon to selected row --- .../tools/cabana/ui/widgets/detailwidget.cc | 87 ++++++------------- .../tools/cabana/ui/widgets/detailwidget.h | 2 + .../tools/cabana/ui/widgets/signalview.cc | 86 ++++++++++++------ .../tools/cabana/ui/widgets/signalview.h | 1 + 4 files changed, 86 insertions(+), 90 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 6bfcccf144..830a51b889 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -52,6 +52,13 @@ DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { signal_view_ = std::make_unique(charts); history_log_ = std::make_unique(); + page_tabs_.addTab("Messages"); + page_tabs_.addTab("Logs"); + connections_.push_back(page_tabs_.currentChanged.connect([this](int index) { + tab_widget_index_ = index; + if (index == 1) history_log_->onShown(); + updateState(); + })); connections_.push_back(binary_view_->signalHovered.connect([this](const cabana::Signal *s) { signal_view_->signalHovered(s); })); connections_.push_back(binary_view_->signalClicked.connect([this](const cabana::Signal *s) { signal_view_->selectSignal(s, true); })); @@ -84,13 +91,16 @@ void DetailWidget::drawToolBar() { }}); items.back().in_menu = false; const size_t spacer_index = items.size(); - const std::string heatmap_text = "Heatmap: " + (heatmap_live_ ? std::string("Live") : heatmap_all_text_); + const std::string heatmap_text = "Heatmap: " + (!heatmap_visible_ ? std::string("Hidden") : heatmap_live_ ? std::string("Live") : heatmap_all_text_); auto heatmap_items = [this]() { - if (dropdown::Item("Live", nullptr, heatmap_live_) && !heatmap_live_) { + if (dropdown::Item("Hidden", nullptr, !heatmap_visible_)) heatmap_visible_ = false; + if (dropdown::Item("Live", nullptr, heatmap_visible_ && heatmap_live_)) { + heatmap_visible_ = true; heatmap_live_ = true; binary_view_->setHeatmapLiveMode(true); } - if (dropdown::Item(heatmap_all_text_.c_str(), nullptr, !heatmap_live_) && heatmap_live_) { + if (dropdown::Item(heatmap_all_text_.c_str(), nullptr, heatmap_visible_ && !heatmap_live_)) { + heatmap_visible_ = true; heatmap_live_ = false; binary_view_->setHeatmapLiveMode(false); } @@ -210,23 +220,18 @@ void DetailWidget::editMsg(float parent_width) { void DetailWidget::drawTabWidget() { const ImGuiStyle &style = ImGui::GetStyle(); - const float pad = style.ItemInnerSpacing.x, pill_height = ImGui::GetFrameHeight() + pad * 2; - ImGui::BeginChild("tab_widget", ImVec2(0, 0), ImGuiChildFlags_None, - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - const ImRect page_rect = ImGui::GetCurrentWindow()->Rect(); - const float gap = style.WindowPadding.y; - ImGui::BeginChild("page", ImVec2(0, std::max(page_rect.GetHeight() - pill_height - gap, 1.0f)), - ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + page_tabs_.draw(); if (tab_widget_index_ == 0) { - // Reserve the signal toolbar and rows before giving the byte grid its preferred height. - const float min_height = binary_view_->minimumSizeHint().y; - const float avail = ImGui::GetContentRegionAvail().y; - const float max_height = std::max(avail - style.ItemSpacing.y - SignalView::minimumHeight(), 1.0f); - const float height = std::clamp(min_height, 1.0f, max_height); - ImGui::BeginChild("binary_view", ImVec2(0, height), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); - binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); - binary_view_->draw(); - ImGui::EndChild(); + if (heatmap_visible_) { + // Keep most of a short window available for signal rows. + const float avail = ImGui::GetContentRegionAvail().y; + const float max_height = std::max(avail - style.ItemSpacing.y - SignalView::minimumHeight(), 1.0f); + const float height = std::min(binary_view_->minimumSizeHint().y, max_height); + ImGui::BeginChild("binary_view", ImVec2(0, std::max(height, 1.0f)), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); + binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); + binary_view_->draw(); + ImGui::EndChild(); + } ImGui::BeginChild("signal_view", ImVec2(0, 0), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); signal_view_rect_ = ImGui::GetCurrentWindow()->Rect(); @@ -235,48 +240,6 @@ void DetailWidget::drawTabWidget() { } else { history_log_->draw(); } - ImGui::EndChild(); - - std::string labels[] = {std::string(icon::FILE_EARMARK_RULED) + " Messages", std::string(icon::STOPWATCH) + " Logs"}; - auto pill_width = [&]() { - float w = pad; - for (const auto &label : labels) w += ImGui::CalcTextSize(label.c_str()).x + style.FramePadding.x * 2 + pad; - return w; - }; - float width = pill_width(); - if (width > page_rect.GetWidth()) { - labels[0] = icon::FILE_EARMARK_RULED; - labels[1] = icon::STOPWATCH; - width = pill_width(); - } - const ImVec2 size(width, pill_height); - const ImVec2 min(std::round(page_rect.GetCenter().x - width * 0.5f), page_rect.Max.y - size.y); - ImGui::SetNextWindowPos(min); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(pad, pad)); - ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::GetColorU32(ImGuiCol_PopupBg)); - ImGui::BeginChild("page_switch", size, ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding, - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(pad, 0.0f)); - for (int i = 0; i < 2; ++i) { - const bool selected = tab_widget_index_ == i; - ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetColorU32(selected ? ImGuiCol_Header : ImGuiCol_Button, selected ? 1.0f : 0.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::GetColorU32(selected ? ImGuiCol_HeaderActive : ImGuiCol_ButtonHovered)); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, selected ? palette().header_active : palette().button_active); - ImGui::PushStyleColor(ImGuiCol_Text, selected ? palette().text_selected : palette().text); - if (i) ImGui::SameLine(); - if (ImGui::Button(labels[i].c_str()) && !selected) { - tab_widget_index_ = i; - if (i == 1) history_log_->onShown(); - updateState(); - } - ImGui::PopStyleColor(4); - } - ImGui::PopStyleVar(2); - ImGui::EndChild(); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(); - ImGui::EndChild(); } void DetailWidget::draw() { @@ -307,7 +270,7 @@ void DetailWidget::draw() { std::vector> DetailWidget::helpRects() const { std::vector> rects; if (tab_widget_index_ == 0) { - rects.emplace_back(binary_view_->whatsThis(), binary_view_rect_); + if (heatmap_visible_) rects.emplace_back(binary_view_->whatsThis(), binary_view_rect_); rects.emplace_back(signal_view_->whatsThis(), signal_view_rect_); } return rects; diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.h b/openpilot/tools/cabana/ui/widgets/detailwidget.h index 7f8efde99c..4745ed5c84 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.h +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.h @@ -82,9 +82,11 @@ private: ElidedLabel name_label_; bool warning_widget_visible_ = false; TabBar tabbar_; + TabBar page_tabs_; int tab_widget_index_ = 0; bool action_remove_msg_enabled_ = false; bool heatmap_live_ = true; + bool heatmap_visible_ = true; std::string heatmap_all_text_ = "All"; ImRect binary_view_rect_, signal_view_rect_; // child window rects of the last drawTabWidget std::unique_ptr history_log_; diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index 5b2eab8b43..bc8b9c4d23 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -244,13 +244,14 @@ float SignalView::textWidth(const std::string &text, float font_size) { } float SignalView::nameColumnWidth(const SignalModel::Item *item, float widget_width, const std::string &text) const { - float spacing = INDENTATION + COLOR_LABEL_WIDTH + 8; + float spacing = INDENTATION + COLOR_LABEL_WIDTH + H_MARGIN * 4; std::string txt = text; if (item->type == SignalModel::Item::Sig && item->sig->type != cabana::Signal::Type::Normal) { txt += multiplexIndicator(item->sig); spacing += H_MARGIN * 2; } - return std::min(widget_width / 3.0, textWidth(txt) + spacing); + const float preferred = ImGui::CalcTextSize(txt.c_str()).x + spacing; + return std::min(widget_width * (compact_ ? 0.6f : 0.4f), preferred); } void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const SignalModel::Item *item, int column, @@ -286,7 +287,7 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const // name if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, false); } else if (column == 1) { - if (!item->sparkline.isEmpty()) { + if (!compact_ && !item->sparkline.isEmpty()) { const ImVec2 sparkline_size = item->sparkline.size; item->sparkline.draw(painter, rect.Min, selected ? text_color : 0); // min-max value @@ -649,7 +650,7 @@ void SignalView::updateState(const std::set *msgs) { max_value_width = std::max(max_value_width, widestValueWidth(item->sig)); } - if (first_visible_row_ != -1 && last_visible_row_ != -1 && last_visible_row_ < model_.rowCount()) { + if (!compact_ && first_visible_row_ != -1 && last_visible_row_ != -1 && last_visible_row_ < model_.rowCount()) { const float min_max_width = textWidth("-000.00", MINMAX_FONT) + 5; float available_width = value_column_width_ - button_size_.x; float value_width = std::min(max_value_width + min_max_width, available_width / 2); @@ -694,7 +695,7 @@ float SignalView::minimumWidth() { float SignalView::minimumHeight() { const ImGuiStyle &style = ImGui::GetStyle(); - return ImGui::GetFrameHeight() + style.ItemSpacing.y + signalRowHeight() * 3 + + return ImGui::GetFrameHeightWithSpacing() + signalRowHeight() * 4 + (style.WindowPadding.y + style.ChildBorderSize + CONTROL_OUTLINE_PADDING) * 2; } @@ -707,26 +708,37 @@ void SignalView::draw() { return; } - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(signal_count_lb_.c_str()); - ImGui::SameLine(); - ImGui::SetNextItemWidth(FILTER_WIDTH); - if (clearableInput("##filter_edit", &filter_edit_, "Filter Signal", nonWhitespaceValidator)) { - model_.setFilter(filter_edit_); - } - - // stretch: the sparkline controls sit at the right edge - alignRight(toolBarRightWidth(sparkline_label_)); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(sparkline_label_.c_str()); - ImGui::SameLine(); - int range = settings.sparkline_range; - if (fusionSliderInt("##sparkline_range_slider", &range, 1, SPARKLINE_RANGE_MAX, SPARKLINE_SLIDER_WIDTH)) { - setSparklineRange(range); - } - ImGui::SetItemTooltip("Sparkline time range"); - ImGui::SameLine(); - if (iconButton("collapse_all", icon::ARROWS_COLLAPSE, "Collapse All")) collapseAll(); + const ImGuiStyle &style = ImGui::GetStyle(); + const float width = ImGui::GetContentRegionAvail().x; + compact_ = width < minimumWidth() - style.WindowPadding.x * 2; + const float count_width = ImGui::CalcTextSize(signal_count_lb_.c_str()).x; + // The standard overflow button keeps the range and collapse actions reachable on narrow panels. + const float filter_width = compact_ ? std::clamp(width - count_width - iconButtonWidth() - style.ItemSpacing.x * 2, + 1.0f, FILTER_WIDTH) : FILTER_WIDTH; + std::vector items; + items.push_back({count_width, [this]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(signal_count_lb_.c_str()); + }}); + items.back().in_menu = false; + items.push_back({filter_width, [this, filter_width]() { + ImGui::SetNextItemWidth(filter_width); + if (clearableInput("##filter_edit", &filter_edit_, "Filter Signal", nonWhitespaceValidator)) { + model_.setFilter(filter_edit_); + } + }}); + items.push_back({toolBarRightWidth(sparkline_label_) - iconButtonWidth() - style.ItemSpacing.x, [this]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(sparkline_label_.c_str()); + ImGui::SameLine(); + int range = settings.sparkline_range; + if (fusionSliderInt("##sparkline_range_slider", &range, 1, SPARKLINE_RANGE_MAX, SPARKLINE_SLIDER_WIDTH)) { + setSparklineRange(range); + } + ImGui::SetItemTooltip("Sparkline time range"); + }}); + items.push_back(toolbarAction("collapse_all", icon::ARROWS_COLLAPSE, "Collapse All", [this]() { collapseAll(); })); + drawToolbar(items, 2); drawTree(); drawValueDescriptionDlg(); @@ -759,6 +771,13 @@ void SignalView::drawTree() { int first_visible = -1, last_visible = -1; auto &children = model_.root()->children; + // Measure before painting so resizing never leaves the old columns for a frame. + auto measure = [&](auto &&self, SignalModel::Item *item) -> void { + ctx.name_width = std::max(ctx.name_width, nameColumnWidth(item, ctx.width, nameText(item))); + if (item->expanded) for (auto child : item->children) self(self, child); + }; + for (auto item : children) measure(measure, item); + if (ctx.name_width > 0) name_column_width_ = ctx.name_width; for (int i = 0; i < children.size(); ++i) { ctx.any_visible = false; const bool header_visible = drawItem(children[i], 0, ctx); @@ -894,15 +913,26 @@ void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); const auto sig = item->sig; const bool checked = item->chart_opened; - if (checked) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); - if (iconButton("plot", icon::GRAPH_UP) && !editor_open_on_press_) { + const bool selected = current_sig_ == sig && current_type_ == SignalModel::Item::Sig; + auto row_button = [selected](const char *id, const char *glyph) { + if (selected) { + ImGui::PushStyleColor(ImGuiCol_Text, palette().text_selected); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, palette().header_active); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, palette().header_active); + } + const bool clicked = iconButton(id, glyph); + if (selected) ImGui::PopStyleColor(3); + return clicked; + }; + if (checked) ImGui::PushStyleColor(ImGuiCol_Button, selected ? palette().header_active : ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); + if (row_button("plot", icon::GRAPH_UP) && !editor_open_on_press_) { item->chart_opened = !checked; showChart(model_.msgId(), sig, item->chart_opened, ImGui::GetIO().KeyShift); } if (checked) ImGui::PopStyleColor(); ImGui::SetItemTooltip("%s", checked ? "Close Plot" : "Show Plot\nShift-click to add to the previously opened plot"); ImGui::SameLine(0.0f, spacing); - if (iconButton("remove", icon::X_LG) && !editor_open_on_press_) { + if (row_button("remove", icon::X_LG) && !editor_open_on_press_) { pending_action_ = [this, sig]() { UndoStack::instance()->push(new RemoveSigCommand(model_.msgId(), sig)); }; } ImGui::SetItemTooltip("Remove signal"); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.h b/openpilot/tools/cabana/ui/widgets/signalview.h index d6ba246097..1ef830ca50 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.h +++ b/openpilot/tools/cabana/ui/widgets/signalview.h @@ -166,6 +166,7 @@ private: static ValidState validateEditor(const SignalModel::Item *item, std::string &text); float value_column_width_ = 0; + bool compact_ = false; float name_column_width_ = 150; bool editor_open_on_press_ = false; // computed while drawing the tree: the first top-level row whose own row is visible (a signal whose header From 26899177578542b7eb7b8b2795f755e73bd17b1c Mon Sep 17 00:00:00 2001 From: Matt Purnell <65473602+mpurnell1@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:43:43 -0500 Subject: [PATCH 112/270] locationd: fix critical_services spelling (#38912) --- openpilot/selfdrive/locationd/locationd.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpilot/selfdrive/locationd/locationd.py b/openpilot/selfdrive/locationd/locationd.py index 9fbec991d5..3c76793e1c 100755 --- a/openpilot/selfdrive/locationd/locationd.py +++ b/openpilot/selfdrive/locationd/locationd.py @@ -278,12 +278,12 @@ def main(): estimator = LocationEstimator(DEBUG) filter_initialized = False - critcal_services = ["accelerometer", "gyroscope", "cameraOdometry"] + critical_services = ["accelerometer", "gyroscope", "cameraOdometry"] observation_input_invalid = defaultdict(int) - input_invalid_limit = {s: round(INPUT_INVALID_LIMIT * (SERVICE_LIST[s].frequency / 20.)) for s in critcal_services} - input_invalid_threshold = {s: input_invalid_limit[s] - 0.5 for s in critcal_services} - input_invalid_decay = {s: calculate_invalid_input_decay(input_invalid_limit[s], INPUT_INVALID_RECOVERY, SERVICE_LIST[s].frequency) for s in critcal_services} + input_invalid_limit = {s: round(INPUT_INVALID_LIMIT * (SERVICE_LIST[s].frequency / 20.)) for s in critical_services} + input_invalid_threshold = {s: input_invalid_limit[s] - 0.5 for s in critical_services} + input_invalid_decay = {s: calculate_invalid_input_decay(input_invalid_limit[s], INPUT_INVALID_RECOVERY, SERVICE_LIST[s].frequency) for s in critical_services} initial_pose_data = params.get("LocationFilterInitialState") if initial_pose_data is not None: @@ -313,7 +313,7 @@ def main(): if valid: t = log_mono_time * 1e-9 res = estimator.handle_log(t, which, msg) - if which not in critcal_services: + if which not in critical_services: continue if res == HandleLogResult.TIMING_INVALID: @@ -328,7 +328,7 @@ def main(): filter_initialized = sm.all_checks() and sensor_all_checks(acc_msgs, gyro_msgs, sensor_valid, sensor_recv_time, sensor_alive, SIMULATION) if sm.updated["cameraOdometry"]: - critical_service_inputs_valid = all(observation_input_invalid[s] < input_invalid_threshold[s] for s in critcal_services) + critical_service_inputs_valid = all(observation_input_invalid[s] < input_invalid_threshold[s] for s in critical_services) inputs_valid = sm.all_valid() and critical_service_inputs_valid sensors_valid = sensor_all_checks(acc_msgs, gyro_msgs, sensor_valid, sensor_recv_time, sensor_alive, SIMULATION) From e6aa405e08e41428fac6dd01bc98f391aaa49ed2 Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:14:38 -0400 Subject: [PATCH 113/270] ui: fix touch validity on alerts menu (#38915) fix touch validity on alerts menu --- openpilot/system/ui/widgets/scroller.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openpilot/system/ui/widgets/scroller.py b/openpilot/system/ui/widgets/scroller.py index 55195b30fb..e79c4592fc 100644 --- a/openpilot/system/ui/widgets/scroller.py +++ b/openpilot/system/ui/widgets/scroller.py @@ -146,9 +146,9 @@ class _Scroller(Widget): # preserve original touch valid callback original_touch_valid_callback = item._touch_valid_callback - item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled and not self._scrolling_to[2] - and not self.moving_items and (original_touch_valid_callback() if - original_touch_valid_callback else True)) + item.set_touch_valid_callback(lambda: self._touch_valid() and self.scroll_panel.is_touch_valid() + and self.enabled and not self._scrolling_to[2] and not self.moving_items + and (original_touch_valid_callback() if original_touch_valid_callback else True)) def add_widgets(self, items: Sequence[Widget]) -> None: for item in items: @@ -397,6 +397,10 @@ class Scroller(Widget): # pass down enabled to child widget for nav stack self._scroller.set_enabled(lambda: self.enabled) + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + super().set_touch_valid_callback(touch_callback) + self._scroller.set_touch_valid_callback(touch_callback) + def _render(self, _, /): self._scroller.render(self._rect) From ffef0e6d3ba10c9fac470b4d316db92f05ca1d35 Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:03:25 -0400 Subject: [PATCH 114/270] ui: custom alert icons (#38917) * custom alerts and alert pill better * add comment back * fix --- openpilot/selfdrive/ui/mici/layouts/home.py | 32 +++++++---------- openpilot/selfdrive/ui/mici/layouts/main.py | 2 +- .../ui/mici/layouts/offroad_alerts.py | 36 ++++++++++++------- 3 files changed, 38 insertions(+), 32 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index 9517701b50..b852bf9ea6 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime import math import time @@ -39,16 +41,13 @@ class AlertsPill(Widget): self.set_rect(rl.Rectangle(0, 0, 104, 52)) self._pill_bg_txt = gui_app.texture("icons_mici/alerts_pill.png", 104, 52) - self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", 36, 36) - self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", 36, 36) - self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 36, 36) self._alert_count_callback: Callable[[], int] | None = None - self._max_severity_callback: Callable[[], int | None] | None = None + self._alert_icon_callback: Callable[[], rl.Texture | None] | None = None def set_alert_count_callback(self, callback: Callable[[], int] | None, - severity_callback: Callable[[], int | None] | None = None): + icon_callback: Callable[[], rl.Texture | None] | None = None): self._alert_count_callback = callback - self._max_severity_callback = severity_callback + self._alert_icon_callback = icon_callback def _render(self, _): alert_count = self._alert_count_callback() if self._alert_count_callback else 0 @@ -56,17 +55,12 @@ class AlertsPill(Widget): pill_w, pill_h = self._pill_bg_txt.width, self._pill_bg_txt.height rl.draw_texture_ex(self._pill_bg_txt, rl.Vector2(self.rect.x, self.rect.y), 0.0, 1.0, rl.WHITE) - severity = self._max_severity_callback() if self._max_severity_callback else None - if severity == -1: - warning_txt = self._icon_green - elif severity is not None and severity > 0: - warning_txt = self._icon_red - else: - warning_txt = self._icon_orange - - warn_x = self.rect.x + self.ICON_OFFSET - warn_y = self.rect.y + (pill_h - warning_txt.height) / 2 - rl.draw_texture_ex(warning_txt, rl.Vector2(warn_x, warn_y), 0.0, 1.0, rl.WHITE) + warning_txt = self._alert_icon_callback() if self._alert_icon_callback else None + if warning_txt is not None: + scale = 36 / max(warning_txt.width, warning_txt.height) + warn_x = self.rect.x + self.ICON_OFFSET + warn_y = self.rect.y + (pill_h - warning_txt.height * scale) / 2 + rl.draw_texture_ex(warning_txt, rl.Vector2(warn_x, warn_y), 0.0, scale, rl.WHITE) count_rect = rl.Rectangle(self.rect.x + self.COUNT_OFFSET, self.rect.y, pill_w - self.COUNT_OFFSET, pill_h) gui_label(count_rect, str(alert_count), font_size=36, @@ -187,11 +181,11 @@ class MiciHomeLayout(Widget): def set_callbacks(self, on_settings: Callable | None = None, on_alerts: Callable | None = None, alert_count_callback: Callable[[], int] | None = None, - max_severity_callback: Callable[[], int | None] | None = None): + alert_icon_callback: Callable[[], rl.Texture | None] | None = None): self._on_settings_click = on_settings self._on_alerts_click = on_alerts self._alert_count_callback = alert_count_callback - self._alerts_pill.set_alert_count_callback(alert_count_callback, max_severity_callback) + self._alerts_pill.set_alert_count_callback(alert_count_callback, alert_icon_callback) def _handle_mouse_release(self, mouse_pos: MousePos): if not self._did_long_press: diff --git a/openpilot/selfdrive/ui/mici/layouts/main.py b/openpilot/selfdrive/ui/mici/layouts/main.py index 7b37747ef7..5364e36da1 100644 --- a/openpilot/selfdrive/ui/mici/layouts/main.py +++ b/openpilot/selfdrive/ui/mici/layouts/main.py @@ -74,7 +74,7 @@ class MiciMainLayout(Scroller): on_settings=lambda: gui_app.push_widget(self._settings_layout), on_alerts=lambda: self._scroll_to(self._alerts_layout), alert_count_callback=self._alerts_layout.active_alerts, - max_severity_callback=self._alerts_layout.max_severity, + alert_icon_callback=self._alerts_layout.highest_severity_icon, ) for layout in (self._car_onroad_layout, self._body_onroad_layout): layout.set_click_callback(lambda: self._scroll_to(self._home_layout)) diff --git a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py index 40e074d455..6175a753bb 100644 --- a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pyray as rl import re import threading @@ -29,6 +31,7 @@ class AlertData: text: str severity: int visible: bool = False + icon: str | None = None class AlertItem(Widget): @@ -56,10 +59,20 @@ class AlertItem(Widget): self._bg_big = gui_app.texture("icons_mici/offroad_alerts/big_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG) self._bg_big_pressed = gui_app.texture("icons_mici/offroad_alerts/big_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG) - # Load warning icons + # Load alert icons self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", self.ICON_SIZE, self.ICON_SIZE) self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", self.ICON_SIZE, self.ICON_SIZE) self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", self.ICON_SIZE, self.ICON_SIZE) + self._custom_icon = gui_app.texture(alert_data.icon, self.ICON_SIZE, self.ICON_SIZE) if alert_data.icon else None + + if self._custom_icon is not None: + self._icon = self._custom_icon + elif alert_data.severity == -1: + self._icon = self._icon_green + elif alert_data.severity > 0: + self._icon = self._icon_red + else: + self._icon = self._icon_orange self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR, alignment=TextAlignment.LEFT, @@ -75,6 +88,10 @@ class AlertItem(Widget): self._update_content() + @property + def icon(self) -> rl.Texture: + return self._icon + def _split_text(self, text: str) -> tuple[str, str]: """Split text into title (first sentence) and body (remaining text).""" # Find the end of the first sentence (period, exclamation, or question mark followed by space or end) @@ -176,16 +193,9 @@ class AlertItem(Widget): self._body_label.render(body_rect) # Draw warning icon on the right side - # Use green icon for update alerts (severity = -1), red for high severity, orange for low severity - if self.alert_data.severity == -1: - icon_texture = self._icon_green - elif self.alert_data.severity > 0: - icon_texture = self._icon_red - else: - icon_texture = self._icon_orange icon_x = self._rect.x + self.ALERT_WIDTH - self.ALERT_PADDING - self.ICON_SIZE icon_y = self._rect.y + self.ALERT_PADDING - rl.draw_texture_ex(icon_texture, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) + rl.draw_texture_ex(self._icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) class MiciOffroadAlerts(Scroller): @@ -214,8 +224,10 @@ class MiciOffroadAlerts(Scroller): def active_alerts(self) -> int: return sum(alert.visible for alert in self.sorted_alerts) - def max_severity(self) -> int | None: - return max((alert.severity for alert in self.sorted_alerts if alert.visible), default=None) + def highest_severity_icon(self) -> rl.Texture | None: + item = max((item for item in self.alert_items if item.alert_data.visible), + key=lambda item: (item.alert_data.severity, bool(item.alert_data.icon)), default=None) + return item.icon if item is not None else None def scrolling(self): return self._scroller.scroll_panel.is_touch_valid() @@ -235,7 +247,7 @@ class MiciOffroadAlerts(Scroller): # Add regular alerts sorted by severity for key, config in sorted(OFFROAD_ALERTS.items(), key=lambda x: x[1].get("severity", 0), reverse=True): severity = config.get("severity", 0) - alert_data = AlertData(key=key, text="", severity=severity) + alert_data = AlertData(key=key, text="", severity=severity, icon=config.get("icon")) self.sorted_alerts.append(alert_data) # Create alert item widget From 2c88d1ed645fc38bbb2621dfa3f7ad61921618c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Mon, 14 Sep 2026 20:20:53 -0700 Subject: [PATCH 115/270] Revert "bump msgq (#38836)" (#38920) This reverts commit 1e0914051fd22d963309b77996181d8b9a97e267. --- msgq_repo | 2 +- pyproject.toml | 4 ++-- uv.lock | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/msgq_repo b/msgq_repo index 326a9f5aa6..0e266c1dbc 160000 --- a/msgq_repo +++ b/msgq_repo @@ -1 +1 @@ -Subproject commit 326a9f5aa6cf630f647fd6996106aa267dd01c2a +Subproject commit 0e266c1dbcf7328beee3e57b4a8688555387c877 diff --git a/pyproject.toml b/pyproject.toml index 2c97e6444e..7be8f97c0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ tools = [ ] submodules = [ - "msgq-ipc", + "msgq", "opendbc", "pandacan", "rednose", @@ -155,7 +155,7 @@ override-dependencies = [ ] [tool.uv.sources] -msgq-ipc = { path = "msgq_repo", editable = true } +msgq = { path = "msgq_repo", editable = true } opendbc = { path = "opendbc_repo", editable = true } pandacan = { path = "panda", editable = true } rednose = { path = "rednose_repo", editable = true } diff --git a/uv.lock b/uv.lock index 9b1ffef34c..8144a10bfa 100644 --- a/uv.lock +++ b/uv.lock @@ -511,8 +511,8 @@ wheels = [ ] [[package]] -name = "msgq-ipc" -version = "1.1" +name = "msgq" +version = "0.0.1" source = { editable = "msgq_repo" } [package.metadata] @@ -617,7 +617,7 @@ dependencies = [ [package.optional-dependencies] submodules = [ - { name = "msgq-ipc" }, + { name = "msgq" }, { name = "opendbc" }, { name = "pandacan" }, { name = "rednose" }, @@ -663,7 +663,7 @@ requires-dist = [ { name = "inputs" }, { name = "jeepney" }, { name = "matplotlib", marker = "extra == 'tools'" }, - { name = "msgq-ipc", marker = "extra == 'submodules'", editable = "msgq_repo" }, + { name = "msgq", marker = "extra == 'submodules'", editable = "msgq_repo" }, { name = "numpy", specifier = ">=2.0" }, { name = "opendbc", marker = "extra == 'submodules'", editable = "opendbc_repo" }, { name = "pandacan", marker = "extra == 'submodules'", editable = "panda" }, From 7b469af4388e15ecb25cb0683dc89d3d22a71857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Mon, 14 Sep 2026 20:21:16 -0700 Subject: [PATCH 116/270] ci: run docs builds on Namespace (#38921) --- .github/workflows/docs.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index c7229190e7..7997b86b7e 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -23,7 +23,12 @@ env: jobs: docs: name: build docs - runs-on: ubuntu-24.04 + runs-on: ${{ + (github.repository == 'commaai/openpilot') && + ((github.event_name != 'pull_request') || + (github.event.pull_request.head.repo.full_name == 'commaai/openpilot')) + && fromJSON('["namespace-profile-amd64-8x16"]') + || fromJSON('["ubuntu-24.04"]') }} steps: - uses: commaai/timeout@v1 - uses: actions/checkout@v7 From 64f9b47b6ea7b622967acd7042148e7a92f0ccc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Mon, 14 Sep 2026 20:44:33 -0700 Subject: [PATCH 117/270] modeld: move history queues into ONNX (#38916) * modeld: move history queues into ONNX * Move ONNX history export into xx exporter * Rename ONNX image input to new_img * Combine ONNX model history state * Revert model history README addition * Keep float32 input packing from master * Rename model input specs to input shapes --- openpilot/selfdrive/modeld/SConscript | 4 +- openpilot/selfdrive/modeld/compile_modeld.py | 120 +++++------------- openpilot/selfdrive/modeld/modeld.py | 15 +-- .../modeld/models/big_driving_supercombo.onnx | 4 +- .../modeld/models/driving_supercombo.onnx | 4 +- 5 files changed, 42 insertions(+), 105 deletions(-) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 2f3e19a315..a302016460 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -6,7 +6,6 @@ from SCons.Script import Action, Value from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_existing_chunks from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE -from openpilot.selfdrive.modeld.constants import ModelConstants from openpilot.selfdrive.modeld.helpers import TG_INPUT_DEVICES_PATH, chestnut_present, modeld_pkl_path @@ -68,7 +67,6 @@ compile_modeld_script = [ File("#openpilot/common/hardware/hw.py"), ] model_w, model_h = MEDMODEL_INPUT_SIZE -frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ for chestnut in [False, True] if CHESTNUT else [False]: target_pkl_path = File(modeld_pkl_path(chestnut)).abspath @@ -81,7 +79,7 @@ for chestnut in [False, True] if CHESTNUT else [False]: f'--model-size {model_w}x{model_h} ' f'--camera-resolutions {camera_res_args} ' f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} ' - f'--output {target_pkl_path} --frame-skip {frame_skip}') + f'--output {target_pkl_path}') onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps) chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum * len(camera_configs))) def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets): diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py index 52be6897c0..c2fd116f1a 100755 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ b/openpilot/selfdrive/modeld/compile_modeld.py @@ -37,7 +37,6 @@ from tinygrad.engine.jit import TinyJit NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size']) -MODELD_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs'] def nv12_copy_size(stride: int, y_height: int, uv_height: int) -> int: @@ -113,58 +112,26 @@ def make_frame_prepare(nv12: NV12Frame, model_w, model_h): return frame_prepare_tinygrad -def get_policy_npy_shapes(input_shapes): - dp = input_shapes['desire_pulse'] # (1, 25, 8) - tc = input_shapes['traffic_convention'] # (1, 2) - at = input_shapes['action_t'] # (1, 2) - fb = input_shapes['features_buffer'] # (1, T-1, ...) e.g. (1, 24, 32, 512) with spatial features - feat_dim = math.prod(fb[2:]) - # TODO prev_feat shouldn't exist and be handled inside the JIT, but corrupt on QCOM for now - shapes = {'desire': (dp[2],), 'traffic_convention': tuple(tc), 'action_t': tuple(at), 'prev_feat': (fb[0], feat_dim)} +def get_npy_shapes(input_shapes, state_pairs): + shapes = {'tfm': (3, 3), 'big_tfm': (3, 3)} | { + name: shape for name, (shape, _) in input_shapes.items() if name not in state_pairs and name != 'new_img'} return shapes, [math.prod(s) for s in shapes.values()] -def make_input_queues(input_shapes, frame_skip, device, frame_copy_size): - img = input_shapes['img'] # (1, 12, 128, 256) - fb = input_shapes['features_buffer'] # (1, T-1, ...), past features only; the model appends the current frame's feature - feat_dim = math.prod(fb[2:]) - dp = input_shapes['desire_pulse'] # (1, 25, 8) - n_frames = img[1] // 6 - img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img[2], img[3]) - - policy_shapes, _ = get_policy_npy_shapes(input_shapes) - shapes = {'tfm': (3, 3), 'big_tfm': (3, 3)} | policy_shapes - sizes = [math.prod(s) for s in shapes.values()] +def make_input_queues(input_shapes, state_pairs, device, frame_copy_size): + shapes, sizes = get_npy_shapes(input_shapes, state_pairs) packed_npy_size = sum(sizes) * np.dtype(np.float32).itemsize packed_input = np.zeros(packed_npy_size + 2 * frame_copy_size, dtype=np.uint8) packed_npy_inputs = packed_input[:packed_npy_size].view(np.float32) frames = packed_input[packed_npy_size:] frame_views = {'img': frames[:frame_copy_size], 'big_img': frames[frame_copy_size:]} - # views into the packed inputs, to be refilled at runtime npy = {k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)} - input_queues = { - 'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), - 'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), - 'feat_q': Tensor(np.zeros((frame_skip * fb[1], fb[0], feat_dim), dtype=np.float32), device=device).contiguous().realize(), - 'desire_q': Tensor(np.zeros((frame_skip * dp[1], dp[0], dp[2]), dtype=np.float32), device=device).contiguous().realize(), - 'packed_npy_inputs': Tensor(packed_input, device='NPY').realize(), - } + input_queues = {name: Tensor(np.zeros(shape, dtype=dtype.fmt), device=device).realize() + for name, (shape, dtype) in input_shapes.items() if name in state_pairs} + input_queues['packed_npy_inputs'] = Tensor(packed_input, device='NPY').realize() return input_queues, npy, frame_views -def shift_and_sample(buf, new_val, sample_fn): - buf.assign(buf[1:].cat(new_val, dim=0).contiguous()) - return sample_fn(buf) - - -def sample_skip(buf, frame_skip): - return buf[::frame_skip].contiguous().flatten(0, 1).unsqueeze(0) - - -def sample_desire(buf, frame_skip): - return buf.reshape(-1, frame_skip, *buf.shape[1:]).max(1).flatten(0, 1).unsqueeze(0) - - def make_warp(nv12, model_w, model_h): frame_prepare = make_frame_prepare(nv12, model_w, model_h) @@ -182,54 +149,27 @@ def make_warp(nv12, model_w, model_h): return warp -def make_run_policy(model_runner, model_metadata, frame_skip): - sample_desire_fn = partial(sample_desire, frame_skip=frame_skip) - sample_skip_fn = partial(sample_skip, frame_skip=frame_skip) - npy_shapes, npy_sizes = get_policy_npy_shapes(model_metadata['input_shapes']) - model_input_dtypes = {name: spec.dtype for name, spec in model_runner.graph_inputs.items()} +def make_run_model(warp, model_runner, input_shapes, state_pairs, frame_copy_size): + shapes, sizes = get_npy_shapes(input_shapes, state_pairs) + packed_npy_size = sum(sizes) * np.dtype(np.float32).itemsize - def run_policy(warped, img_q, big_img_q, feat_q, desire_q, packed_npy_inputs): - packed_npy_inputs = packed_npy_inputs.to(Device.DEFAULT) - Tensor.realize(packed_npy_inputs, warped) - - img = shift_and_sample(img_q, warped[0:1], sample_skip_fn) - big_img = shift_and_sample(big_img_q, warped[1:2], sample_skip_fn) - - desire, traffic_convention, action_t, prev_feat = (t.reshape(s) for t, s in zip(packed_npy_inputs.split(npy_sizes), npy_shapes.values(), strict=True)) - desire_buf = shift_and_sample(desire_q, desire.reshape(1, 1, -1), sample_desire_fn) - feat_buf = shift_and_sample(feat_q, prev_feat.reshape(1, 1, -1), sample_skip_fn) - - inputs = { - 'img': img, - 'big_img': big_img, - 'features_buffer': feat_buf.reshape(model_metadata['input_shapes']['features_buffer']), - 'desire_pulse': desire_buf, - 'traffic_convention': traffic_convention, - 'action_t': action_t, - } - inputs = {name: value.cast(model_input_dtypes[name]) for name, value in inputs.items()} - out = next(iter(model_runner(inputs).values())).cast('float32') - return out, - return run_policy - - -def make_run_model(warp, run_policy, model_metadata, frame_copy_size): - _, policy_sizes = get_policy_npy_shapes(model_metadata['input_shapes']) - packed_npy_size = (18 + sum(policy_sizes)) * np.dtype(np.float32).itemsize - - def run_model(img_q, big_img_q, feat_q, desire_q, packed_npy_inputs): - packed_input = packed_npy_inputs.to(Device.DEFAULT) - Tensor.realize(packed_input) + def run_model(packed_npy_inputs, **state_inputs): + packed_input = packed_npy_inputs.to(Device.DEFAULT).realize() packed_npy_inputs = packed_input[:packed_npy_size].bitcast('float32') + inputs = {name: t.reshape(s) for (name, s), t in zip(shapes.items(), packed_npy_inputs.split(sizes), strict=True)} frame = packed_input[packed_npy_size:packed_npy_size + frame_copy_size] big_frame = packed_input[packed_npy_size + frame_copy_size:] - tfm, big_tfm, policy_inputs = packed_npy_inputs.split([9, 9, sum(policy_sizes)]) - warped = warp(tfm.reshape(3, 3), big_tfm.reshape(3, 3), frame, big_frame) - return run_policy(warped, img_q, big_img_q, feat_q, desire_q, policy_inputs) + inputs['new_img'] = warp(inputs.pop('tfm'), inputs.pop('big_tfm'), frame, big_frame) + inputs = {name: value.cast(input_shapes[name][1]) for name, value in inputs.items()} + outputs = {name: value.contiguous() for name, value in model_runner(inputs | state_inputs).items()} + Tensor.realize(*outputs.values()) + if state_pairs: + Tensor.realize(*(state_inputs[name].assign(outputs[next_name]) for name, next_name in state_pairs.items())) + return tuple(value for name, value in outputs.items() if name not in state_pairs.values()) return run_model -def compile_jit(jit, input_keys, make_queues, benchmark_runs): +def compile_jit(jit, make_queues, benchmark_runs): if benchmark_runs < 1: raise ValueError("benchmark_runs must be at least 1") @@ -245,7 +185,7 @@ def compile_jit(jit, input_keys, make_queues, benchmark_runs): v[:] = rng.integers(0, 256, size=v.shape, dtype=np.uint8) Device.default.synchronize() st = time.perf_counter() - outs = fn(**{k: input_queues[k] for k in input_keys}) + outs = fn(**input_queues) mt = time.perf_counter() Device.default.synchronize() et = time.perf_counter() @@ -300,7 +240,6 @@ if __name__ == "__main__": help='camera resolutions WxH (one or more)') p.add_argument('--onnx', required=True) p.add_argument('--output', required=True) - p.add_argument('--frame-skip', type=int, required=True) p.add_argument('--benchmark-runs', type=int, default=1, help='timed loaded-JIT runs for each correctness seed') args = p.parse_args() @@ -309,23 +248,24 @@ if __name__ == "__main__": model_w, model_h = args.model_size model_runner = OnnxRunner(model_path) + input_shapes = {name: (spec.shape, spec.dtype) for name, spec in model_runner.graph_inputs.items()} + state_pairs = {name: f'next_{name}' for name in input_shapes if f'next_{name}' in model_runner.graph_outputs} out = { 'metadata': make_metadata_dict(model_path), + 'input_shapes': input_shapes, + 'state_pairs': state_pairs, 'input_devices': {'model': Device.DEFAULT}, 'run_model': {}, } - run_policy = make_run_policy(model_runner, out['metadata'], args.frame_skip) - for cam_w, cam_h in args.camera_resolutions: nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) frame_copy_size = nv12_copy_size(nv12.stride, nv12.y_height, nv12.uv_height) - make_model_queues = partial(make_input_queues, out['metadata']['input_shapes'], args.frame_skip, + make_model_queues = partial(make_input_queues, input_shapes, state_pairs, frame_copy_size=frame_copy_size) warp = make_warp(nv12, model_w, model_h) - run_model_jit = TinyJit(make_run_model(warp, run_policy, out['metadata'], frame_copy_size), prune=True) - out['run_model'][(cam_w,cam_h)] = compile_jit(run_model_jit, MODELD_INPUTS, make_model_queues, - args.benchmark_runs) + run_model_jit = TinyJit(make_run_model(warp, model_runner, input_shapes, state_pairs, frame_copy_size), prune=True) + out['run_model'][(cam_w,cam_h)] = compile_jit(run_model_jit, make_model_queues, args.benchmark_runs) with open(args.output, "wb") as f: dump_oob(out, f) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index a05361145a..994fda8b35 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -26,7 +26,7 @@ from openpilot.common.transformations.model import get_warp_matrix from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, should_stop, smooth_value, get_curvature_from_plan from openpilot.selfdrive.modeld.parse_model_outputs import Parser -from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, nv12_copy_size, MODELD_INPUTS +from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, nv12_copy_size from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.constants import ModelConstants, Plan @@ -132,17 +132,17 @@ class ModelState: input_devices = jits['input_devices'] self.model_device = input_devices['model'] metadata = jits['metadata'] - self.input_shapes = metadata['input_shapes'] - self.vision_input_names = [k for k in self.input_shapes if 'img' in k] + self.input_shapes = jits['input_shapes'] + self.state_pairs = jits['state_pairs'] + self.vision_input_names = ('img', 'big_img') self.output_slices = metadata['output_slices'] self.prev_desire = np.zeros(ModelConstants.DESIRE_LEN, dtype=np.float32) self.chestnut = chestnut - self.frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ self.frame_copy_size = nv12_copy_size(*get_nv12_info(cam_w, cam_h)[:3]) self.input_queues, self.npy, self.frame_views = make_input_queues( - self.input_shapes, self.frame_skip, device=self.model_device, frame_copy_size=self.frame_copy_size) + self.input_shapes, self.state_pairs, device=self.model_device, frame_copy_size=self.frame_copy_size) self.parser = Parser() self.run_model = jits['run_model'][(cam_w,cam_h)] @@ -164,14 +164,13 @@ class ModelState: self.npy['tfm'][:,:] = transforms['img'][:,:] self.npy['big_tfm'][:,:] = transforms['big_img'][:,:] - outs, = self.run_model(**{k: self.input_queues[k] for k in MODELD_INPUTS}) + outs, = self.run_model(**self.input_queues) if after_enqueue is not None: after_enqueue() model_output = outs.numpy()[0] if self.chestnut and not np.all(np.isfinite(model_output)): raise RuntimeError("model output not finite") outputs_dict = self.parser.parse_outputs(self.slice_outputs(model_output, self.output_slices)) - self.npy['prev_feat'][:] = model_output[self.output_slices['hidden_state']] if SEND_RAW_PRED: outputs_dict['raw_pred'] = model_output.copy() @@ -183,7 +182,7 @@ class ModelState: dims = {'desire_pulse': ModelConstants.DESIRE_LEN, 'traffic_convention': 2, 'action_t': 2} self.run(dummy_frames, dict.fromkeys(self.vision_input_names, eye), {k: np.zeros(v, dtype=np.float32) for k, v in dims.items()}) self.input_queues, self.npy, self.frame_views = make_input_queues( - self.input_shapes, self.frame_skip, device=self.model_device, frame_copy_size=self.frame_copy_size) + self.input_shapes, self.state_pairs, device=self.model_device, frame_copy_size=self.frame_copy_size) self.prev_desire[:] = 0 diff --git a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx index 13e3d32f47..3646adc744 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx +++ b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:09d080f36965bb2a0790500452bd328aa03c484d0222aa79d1ad9f021a522aec -size 766040736 +oid sha256:6fee5937923c74848df4a63f6239eb6331c6274dd4bdb7a5d6ec0388a8b543d5 +size 766018462 diff --git a/openpilot/selfdrive/modeld/models/driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/driving_supercombo.onnx index f0672eab48..f030157ccd 100644 --- a/openpilot/selfdrive/modeld/models/driving_supercombo.onnx +++ b/openpilot/selfdrive/modeld/models/driving_supercombo.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:659727c4d4839adc4992a254409a54259a8756a743f2d567bf5fdc6579f8009b -size 60881999 +oid sha256:65a08adc31d5c456219687d99b7bf5e44d61dae2d49ea67850e76105c7248cce +size 60918562 From fa9f56ed766182f3c30f37c4155f363749168520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Mon, 14 Sep 2026 23:07:26 -0700 Subject: [PATCH 118/270] Compile warp and onnx separately (#38864) * Extract shared model warp routines * Move DM warp compilation into compile_warp * Configure shared warp compilation through SCons flags * Move the driving warp wrapper into compile_warp * Build driving warps separately from the model * Compile model inputs directly from ONNX * modeld: simplify split input setup * modeld: extract input packing setup * modeld: simplify packed views and share firmware setup --- openpilot/selfdrive/modeld/SConscript | 35 +++- openpilot/selfdrive/modeld/compile_dm_warp.py | 56 ----- openpilot/selfdrive/modeld/compile_modeld.py | 192 ++---------------- openpilot/selfdrive/modeld/compile_warp.py | 150 ++++++++++++++ openpilot/selfdrive/modeld/helpers.py | 15 ++ openpilot/selfdrive/modeld/modeld.py | 68 +++++-- 6 files changed, 255 insertions(+), 261 deletions(-) delete mode 100755 openpilot/selfdrive/modeld/compile_dm_warp.py create mode 100644 openpilot/selfdrive/modeld/compile_warp.py diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index a302016460..6af7f09092 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -7,6 +7,7 @@ from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_exi from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE from openpilot.selfdrive.modeld.helpers import TG_INPUT_DEVICES_PATH, chestnut_present, modeld_pkl_path +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info Import('env', 'arch') @@ -63,25 +64,23 @@ compile_modeld_script = [ File(f"{modeld_dir}/compile_modeld.py"), File(f"{modeld_dir}/get_model_metadata.py"), File(f"{modeld_dir}/helpers.py"), - File("#openpilot/system/camerad/cameras/nv12_info.py"), File("#openpilot/common/hardware/hw.py"), ] +compile_warp_script = [File(f"{modeld_dir}/compile_warp.py"), File(f"{modeld_dir}/helpers.py"), + File("#openpilot/system/camerad/cameras/nv12_info.py")] model_w, model_h = MEDMODEL_INPUT_SIZE for chestnut in [False, True] if CHESTNUT else [False]: target_pkl_path = File(modeld_pkl_path(chestnut)).abspath file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath) - camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in camera_configs) # CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_modeld.py ' - f'--model-size {model_w}x{model_h} ' - f'--camera-resolutions {camera_res_args} ' f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} ' f'--output {target_pkl_path}') onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps) - chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum * len(camera_configs))) + chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets): from openpilot.system.hardware.chestnut.flash import link_up # chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars @@ -94,18 +93,33 @@ for chestnut in [False, True] if CHESTNUT else [False]: return if ret := env.Execute(command): return ret - chunk_file(pkl, chunks) + if chunks: + chunk_file(pkl, chunks) def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets): chunk_file(pkl, chunks) actions = Action(do_compile, " [CHESTNUT] $TARGET") if chestnut else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] node = lenv.Command( chunk_targets, - tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(camera_res_args), Value(chunk_targets), chunker_file], + tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(cmd), Value(chunk_targets), chunker_file], actions, ) if chestnut: lenv.SideEffect(chestnut_lock, node) + for cam_w, cam_h in camera_configs: + warp_pkl_path = File(f"models/{file_prefix}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath + stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) + cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_warp.py ' + f'--camera-resolution {cam_w}x{cam_h} --warp-to {model_w}x{model_h} --layout yuv420 ' + f'--frames 2 --frame-size {stride * (y_height + uv_height)} ' + f'--output {warp_pkl_path}') + def do_compile_warp(target, source, env, command=cmd): + return do_compile(target, source, env, command=command, chunks=()) + action = Action(do_compile_warp, " [CHESTNUT] $TARGET") if chestnut else cmd + node = lenv.Command(warp_pkl_path, tinygrad_files + compile_warp_script + [Value(cmd)], action) + if chestnut: + lenv.SideEffect(chestnut_lock, node) + # get model metadata fn = File(f"models/dmonitoring_model").abspath script_files = [File(Dir("#openpilot/selfdrive/modeld").File("get_model_metadata.py").abspath)] @@ -113,13 +127,12 @@ cmd = f'{tg_flags} {mac_brew_string} python3 {Dir("#openpilot/selfdrive/modeld") lenv.Command(fn + "_metadata.pkl", [fn + ".onnx"] + tinygrad_files + script_files + [tg_devices_node], cmd) dm_w, dm_h = DM_INPUT_SIZE -compile_dm_warp_script = [File(f"{modeld_dir}/compile_dm_warp.py")] for cam_w, cam_h in camera_configs: dm_pkl_path = File(f"models/dm_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath - cmd = (f'{tg_flags} {mac_brew_string} python3 {modeld_dir}/compile_dm_warp.py ' - f'--camera-resolution {cam_w}x{cam_h} --warp-to {dm_w}x{dm_h} ' + cmd = (f'{tg_flags} {mac_brew_string} python3 {modeld_dir}/compile_warp.py ' + f'--camera-resolution {cam_w}x{cam_h} --warp-to {dm_w}x{dm_h} --layout luma --border-fill 16 --transform-device NPY ' f'--output {dm_pkl_path}') - lenv.Command(dm_pkl_path, tinygrad_files + compile_dm_warp_script + compile_modeld_script + [tg_devices_node], cmd) + lenv.Command(dm_pkl_path, tinygrad_files + compile_warp_script + [tg_devices_node], cmd) def tg_compile(flags, model_name): pythonpath_string = 'PYTHONPATH="${PYTHONPATH}:' + env.Dir("#tinygrad_repo").abspath + '"' diff --git a/openpilot/selfdrive/modeld/compile_dm_warp.py b/openpilot/selfdrive/modeld/compile_dm_warp.py deleted file mode 100755 index 16e0d39fbb..0000000000 --- a/openpilot/selfdrive/modeld/compile_dm_warp.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import pickle -import time - -from tinygrad.tensor import Tensor -from tinygrad.device import Device -from tinygrad.engine.jit import TinyJit - -from openpilot.system.camerad.cameras.nv12_info import get_nv12_info -from openpilot.selfdrive.modeld.compile_modeld import NV12Frame, warp_perspective_tinygrad, _parse_size - - -def make_warp_dm(nv12: NV12Frame, dm_w, dm_h): - cam_w, cam_h, stride, _, _, _ = nv12 - stride_pad = stride - cam_w - - def warp_dm(input_frame, M_inv): - M_inv = M_inv.to(Device.DEFAULT).realize() - return warp_perspective_tinygrad(input_frame[:cam_h*stride], M_inv, - (dm_w, dm_h), (cam_h, cam_w), stride_pad, border_fill_val=16).reshape(-1, dm_h * dm_w) # Y - return warp_dm - - -def compile_dm_warp(nv12: NV12Frame, dm_w, dm_h, pkl_path): - print(f"Compiling DM warp for {nv12.width}x{nv12.height} -> {dm_w}x{dm_h}...") - - warp_dm_jit = TinyJit(make_warp_dm(nv12, dm_w, dm_h), prune=True) - - for i in range(10): - frame = Tensor.randint(nv12.size, low=0, high=256, dtype='uint8').realize() - M_inv = Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY') - Device.default.synchronize() - st = time.perf_counter() - warp_dm_jit(frame, M_inv).realize() - mt = time.perf_counter() - Device.default.synchronize() - et = time.perf_counter() - print(f" [{i+1}/10] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") - - with open(pkl_path, "wb") as f: - pickle.dump(warp_dm_jit, f) - print(f" Saved to {pkl_path}") - - -if __name__ == "__main__": - p = argparse.ArgumentParser() - p.add_argument('--camera-resolution', type=_parse_size, required=True, help='camera resolution WxH') - p.add_argument('--warp-to', type=_parse_size, required=True, help='DM input WxH') - p.add_argument('--output', required=True) - args = p.parse_args() - - cam_w, cam_h = args.camera_resolution - nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - dm_w, dm_h = args.warp_to - compile_dm_warp(nv12, dm_w, dm_h, args.output) diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py index c2fd116f1a..463e697519 100755 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ b/openpilot/selfdrive/modeld/compile_modeld.py @@ -1,188 +1,48 @@ #!/usr/bin/env python3 import argparse import atexit -import math import os import tempfile import time import shutil -from functools import partial -from collections import namedtuple import numpy as np -from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob - -def _patch_tinygrad_fetch_fw(): - import hashlib - import pathlib - import zstandard - from tinygrad import helpers - _orig = helpers.fetch_fw - def fetch_fw(path, name, sha256): - p = pathlib.Path(f"/lib/firmware/{path}/{name}.zst") - if p.is_file(): - blob = zstandard.ZstdDecompressor().stream_reader(p.read_bytes()).read() - if hashlib.sha256(blob).hexdigest() == sha256: - return blob - return _orig(path, name, sha256) - helpers.fetch_fw = fetch_fw -_patch_tinygrad_fetch_fw() - +from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob, patch_tinygrad_fetch_fw +patch_tinygrad_fetch_fw() from tinygrad.tensor import Tensor -from tinygrad.helpers import Context from tinygrad.device import Device from tinygrad.engine.jit import TinyJit -NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size']) +def make_input_queues(input_shapes, device): + return {name: Tensor(np.zeros(shape, dtype=dtype.fmt), device=device).realize() for name, (shape, dtype) in input_shapes.items()} -def nv12_copy_size(stride: int, y_height: int, uv_height: int) -> int: - # Retain the padded Y and UV plane storage, but skip the trailing kernel/guard allocation. - return stride * (y_height + uv_height) - - -def warp_perspective_tinygrad(src_flat, M_inv, dst_shape, src_shape, stride_pad, border_fill_val=None): - w_dst, h_dst = dst_shape - h_src, w_src = src_shape - - x = Tensor.arange(w_dst).reshape(1, w_dst).expand(h_dst, w_dst).reshape(-1) - y = Tensor.arange(h_dst).reshape(h_dst, 1).expand(h_dst, w_dst).reshape(-1) - - # inline 3x3 matmul as elementwise to avoid reduce op (enables fusion with gather) - src_x = M_inv[0, 0] * x + M_inv[0, 1] * y + M_inv[0, 2] - src_y = M_inv[1, 0] * x + M_inv[1, 1] * y + M_inv[1, 2] - src_w = M_inv[2, 0] * x + M_inv[2, 1] * y + M_inv[2, 2] - - src_x = src_x / src_w - src_y = src_y / src_w - - x_round = Tensor.round(src_x) - y_round = Tensor.round(src_y) - x_nn_clipped = x_round.clip(0, w_src - 1).cast('int') - y_nn_clipped = y_round.clip(0, h_src - 1).cast('int') - idx = y_nn_clipped * (w_src + stride_pad) + x_nn_clipped - sampled = src_flat[idx] - - if border_fill_val is None: - return sampled - - in_bounds = ((x_round >= 0) & (x_round <= w_src - 1) & - (y_round >= 0) & (y_round <= h_src - 1)).cast(sampled.dtype) - return sampled * in_bounds + Tensor(border_fill_val, dtype=sampled.dtype) * (1 - in_bounds) - - -def frames_to_tensor(frames): - H = (frames.shape[0] * 2) // 3 - W = frames.shape[1] - in_img1 = Tensor.cat(frames[0:H:2, 0::2], - frames[1:H:2, 0::2], - frames[0:H:2, 1::2], - frames[1:H:2, 1::2], - frames[H:H+H//4].reshape((H//2, W//2)), - frames[H+H//4:H+H//2].reshape((H//2, W//2)), dim=0).reshape((6, H//2, W//2)) - return in_img1 - - -def make_frame_prepare(nv12: NV12Frame, model_w, model_h): - cam_w, cam_h, stride, y_height, uv_height, _ = nv12 - uv_offset = stride * y_height - stride_pad = stride - cam_w - - def frame_prepare_tinygrad(input_frame, M_inv): - # UV_SCALE @ M_inv @ UV_SCALE_INV simplifies to elementwise scaling - M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=Device.DEFAULT) - # deinterleave NV12 UV plane (UVUV... -> separate U, V) - uv = input_frame[uv_offset:uv_offset + uv_height * stride].reshape(uv_height, stride) - with Context(SPLIT_REDUCEOP=0): - y = warp_perspective_tinygrad(input_frame[:cam_h*stride], - M_inv, (model_w, model_h), - (cam_h, cam_w), stride_pad).realize() - u = warp_perspective_tinygrad(uv[:cam_h//2, :cam_w:2].flatten(), - M_inv_uv, (model_w//2, model_h//2), - (cam_h//2, cam_w//2), 0).realize() - v = warp_perspective_tinygrad(uv[:cam_h//2, 1:cam_w:2].flatten(), - M_inv_uv, (model_w//2, model_h//2), - (cam_h//2, cam_w//2), 0).realize() - yuv = y.cat(u).cat(v).reshape((model_h * 3 // 2, model_w)) - tensor = frames_to_tensor(yuv) - return tensor - return frame_prepare_tinygrad - - -def get_npy_shapes(input_shapes, state_pairs): - shapes = {'tfm': (3, 3), 'big_tfm': (3, 3)} | { - name: shape for name, (shape, _) in input_shapes.items() if name not in state_pairs and name != 'new_img'} - return shapes, [math.prod(s) for s in shapes.values()] - - -def make_input_queues(input_shapes, state_pairs, device, frame_copy_size): - shapes, sizes = get_npy_shapes(input_shapes, state_pairs) - packed_npy_size = sum(sizes) * np.dtype(np.float32).itemsize - packed_input = np.zeros(packed_npy_size + 2 * frame_copy_size, dtype=np.uint8) - packed_npy_inputs = packed_input[:packed_npy_size].view(np.float32) - frames = packed_input[packed_npy_size:] - frame_views = {'img': frames[:frame_copy_size], 'big_img': frames[frame_copy_size:]} - npy = {k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)} - input_queues = {name: Tensor(np.zeros(shape, dtype=dtype.fmt), device=device).realize() - for name, (shape, dtype) in input_shapes.items() if name in state_pairs} - input_queues['packed_npy_inputs'] = Tensor(packed_input, device='NPY').realize() - return input_queues, npy, frame_views - - -def make_warp(nv12, model_w, model_h): - frame_prepare = make_frame_prepare(nv12, model_w, model_h) - - def warp(tfm, big_tfm, frame, big_frame): - tfm = tfm.to(Device.DEFAULT) - big_tfm = big_tfm.to(Device.DEFAULT) - frame = frame.to(Device.DEFAULT) - big_frame = big_frame.to(Device.DEFAULT) - Tensor.realize(tfm, big_tfm, frame, big_frame) - - warped_frame = frame_prepare(frame, tfm).unsqueeze(0) - warped_big_frame = frame_prepare(big_frame, big_tfm).unsqueeze(0) - return Tensor.cat(warped_frame, warped_big_frame) - - return warp - - -def make_run_model(warp, model_runner, input_shapes, state_pairs, frame_copy_size): - shapes, sizes = get_npy_shapes(input_shapes, state_pairs) - packed_npy_size = sum(sizes) * np.dtype(np.float32).itemsize - - def run_model(packed_npy_inputs, **state_inputs): - packed_input = packed_npy_inputs.to(Device.DEFAULT).realize() - packed_npy_inputs = packed_input[:packed_npy_size].bitcast('float32') - inputs = {name: t.reshape(s) for (name, s), t in zip(shapes.items(), packed_npy_inputs.split(sizes), strict=True)} - frame = packed_input[packed_npy_size:packed_npy_size + frame_copy_size] - big_frame = packed_input[packed_npy_size + frame_copy_size:] - inputs['new_img'] = warp(inputs.pop('tfm'), inputs.pop('big_tfm'), frame, big_frame) - inputs = {name: value.cast(input_shapes[name][1]) for name, value in inputs.items()} - outputs = {name: value.contiguous() for name, value in model_runner(inputs | state_inputs).items()} +def make_run_model(model_runner, state_pairs): + def run_model(**inputs): + outputs = {name: value.contiguous() for name, value in model_runner(inputs).items()} Tensor.realize(*outputs.values()) if state_pairs: - Tensor.realize(*(state_inputs[name].assign(outputs[next_name]) for name, next_name in state_pairs.items())) + Tensor.realize(*(inputs[name].assign(outputs[next_name]) for name, next_name in state_pairs.items())) return tuple(value for name, value in outputs.items() if name not in state_pairs.values()) return run_model -def compile_jit(jit, make_queues, benchmark_runs): +def compile_jit(jit, input_shapes, benchmark_runs): if benchmark_runs < 1: raise ValueError("benchmark_runs must be at least 1") SEED = 42 def random_inputs_run(fn, seed, n_runs, test_val=None, test_buffers=None, expect_match=True): - input_queues, npy, frame_views = make_queues(Device.DEFAULT) + input_queues = make_input_queues(input_shapes, Device.DEFAULT) rng = np.random.default_rng(seed) for i in range(n_runs): - for v in npy.values(): - v[:] = rng.standard_normal(v.shape).astype(v.dtype) - for v in frame_views.values(): - v[:] = rng.integers(0, 256, size=v.shape, dtype=np.uint8) + for value in input_queues.values(): + values = rng.standard_normal(value.shape) if np.issubdtype(np.dtype(value.dtype.fmt), np.floating) else rng.integers(0, 256, value.shape) + value.assign(Tensor(values.astype(value.dtype.fmt), device=Device.DEFAULT)).realize() Device.default.synchronize() st = time.perf_counter() outs = fn(**input_queues) @@ -192,8 +52,8 @@ def compile_jit(jit, make_queues, benchmark_runs): print(f" [{i+1}/{n_runs}] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") if i == 0: - val = [np.copy(v.numpy()) for v in outs] - buffers = [np.copy(v.numpy().copy()) for v in input_queues.values()] + val = [v.numpy() for v in outs] + buffers = [v.numpy() for v in input_queues.values()] if test_val is not None: match = all(np.array_equal(a, b) for a, b in zip(val, test_val, strict=True)) @@ -212,15 +72,9 @@ def compile_jit(jit, make_queues, benchmark_runs): loaded_jit = load_oob(f) random_inputs_run(loaded_jit, SEED, benchmark_runs, test_val, test_buffers, expect_match=True) random_inputs_run(loaded_jit, SEED+1, benchmark_runs, test_val, test_buffers, expect_match=False) - # Keep the original so per-resolution JITs share model weight buffers in the final pickle. return jit -def _parse_size(s): - w, h = s.lower().split('x') - return int(w), int(h) - - def read_file_chunked_to_disk(path): from openpilot.common.file_chunker import open_file_chunked tmp_path = f'{path}.unchunked' @@ -232,12 +86,8 @@ def read_file_chunked_to_disk(path): if __name__ == "__main__": from tinygrad.nn.onnx import OnnxRunner - from openpilot.system.camerad.cameras.nv12_info import get_nv12_info from openpilot.selfdrive.modeld.get_model_metadata import make_metadata_dict p = argparse.ArgumentParser() - p.add_argument('--model-size', type=_parse_size, required=True, help='model input WxH') - p.add_argument('--camera-resolutions', type=_parse_size, nargs='+', required=True, - help='camera resolutions WxH (one or more)') p.add_argument('--onnx', required=True) p.add_argument('--output', required=True) p.add_argument('--benchmark-runs', type=int, default=1, @@ -245,7 +95,6 @@ if __name__ == "__main__": args = p.parse_args() model_path = read_file_chunked_to_disk(args.onnx) - model_w, model_h = args.model_size model_runner = OnnxRunner(model_path) input_shapes = {name: (spec.shape, spec.dtype) for name, spec in model_runner.graph_inputs.items()} @@ -255,17 +104,10 @@ if __name__ == "__main__": 'input_shapes': input_shapes, 'state_pairs': state_pairs, 'input_devices': {'model': Device.DEFAULT}, - 'run_model': {}, } - for cam_w, cam_h in args.camera_resolutions: - nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - frame_copy_size = nv12_copy_size(nv12.stride, nv12.y_height, nv12.uv_height) - make_model_queues = partial(make_input_queues, input_shapes, state_pairs, - frame_copy_size=frame_copy_size) - warp = make_warp(nv12, model_w, model_h) - run_model_jit = TinyJit(make_run_model(warp, model_runner, input_shapes, state_pairs, frame_copy_size), prune=True) - out['run_model'][(cam_w,cam_h)] = compile_jit(run_model_jit, make_model_queues, args.benchmark_runs) + run_model = make_run_model(model_runner, state_pairs) + out['run_model'] = compile_jit(TinyJit(run_model, prune=True), input_shapes, args.benchmark_runs) with open(args.output, "wb") as f: dump_oob(out, f) diff --git a/openpilot/selfdrive/modeld/compile_warp.py b/openpilot/selfdrive/modeld/compile_warp.py new file mode 100644 index 0000000000..1b2c085537 --- /dev/null +++ b/openpilot/selfdrive/modeld/compile_warp.py @@ -0,0 +1,150 @@ +import argparse +import pickle +import time +from collections import namedtuple + +from openpilot.selfdrive.modeld.helpers import patch_tinygrad_fetch_fw +patch_tinygrad_fetch_fw() + +from tinygrad.tensor import Tensor +from tinygrad.helpers import Context +from tinygrad.device import Device +from tinygrad.engine.jit import TinyJit + +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info + + +NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size']) + + +def warp_perspective_tinygrad(src_flat, M_inv, dst_shape, src_shape, stride_pad, border_fill_val=None): + w_dst, h_dst = dst_shape + h_src, w_src = src_shape + + x = Tensor.arange(w_dst).reshape(1, w_dst).expand(h_dst, w_dst).reshape(-1) + y = Tensor.arange(h_dst).reshape(h_dst, 1).expand(h_dst, w_dst).reshape(-1) + + # inline 3x3 matmul as elementwise to avoid reduce op (enables fusion with gather) + src_x = M_inv[0, 0] * x + M_inv[0, 1] * y + M_inv[0, 2] + src_y = M_inv[1, 0] * x + M_inv[1, 1] * y + M_inv[1, 2] + src_w = M_inv[2, 0] * x + M_inv[2, 1] * y + M_inv[2, 2] + + src_x = src_x / src_w + src_y = src_y / src_w + + x_round = Tensor.round(src_x) + y_round = Tensor.round(src_y) + x_nn_clipped = x_round.clip(0, w_src - 1).cast('int') + y_nn_clipped = y_round.clip(0, h_src - 1).cast('int') + idx = y_nn_clipped * (w_src + stride_pad) + x_nn_clipped + sampled = src_flat[idx] + + if border_fill_val is None: + return sampled + + in_bounds = ((x_round >= 0) & (x_round <= w_src - 1) & + (y_round >= 0) & (y_round <= h_src - 1)).cast(sampled.dtype) + return sampled * in_bounds + Tensor(border_fill_val, dtype=sampled.dtype) * (1 - in_bounds) + + +def frames_to_tensor(frames): + H = (frames.shape[0] * 2) // 3 + W = frames.shape[1] + return Tensor.cat(frames[0:H:2, 0::2], + frames[1:H:2, 0::2], + frames[0:H:2, 1::2], + frames[1:H:2, 1::2], + frames[H:H+H//4].reshape((H//2, W//2)), + frames[H+H//4:H+H//2].reshape((H//2, W//2)), dim=0).reshape((6, H//2, W//2)) + + +def make_frame_prepare(nv12: NV12Frame, model_w, model_h, layout="yuv420", border_fill=None): + cam_w, cam_h, stride, y_height, uv_height, _ = nv12 + uv_offset = stride * y_height + stride_pad = stride - cam_w + + def frame_prepare_tinygrad(input_frame, M_inv): + if layout == "luma": + return warp_perspective_tinygrad(input_frame[:cam_h*stride], M_inv, + (model_w, model_h), (cam_h, cam_w), stride_pad, + border_fill_val=border_fill).reshape(-1, model_h * model_w) + # UV_SCALE @ M_inv @ UV_SCALE_INV simplifies to elementwise scaling + M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=Device.DEFAULT) + # deinterleave NV12 UV plane (UVUV... -> separate U, V) + uv = input_frame[uv_offset:uv_offset + uv_height * stride].reshape(uv_height, stride) + with Context(SPLIT_REDUCEOP=0): + y = warp_perspective_tinygrad(input_frame[:cam_h*stride], + M_inv, (model_w, model_h), + (cam_h, cam_w), stride_pad, border_fill_val=border_fill).realize() + u = warp_perspective_tinygrad(uv[:cam_h//2, :cam_w:2].flatten(), + M_inv_uv, (model_w//2, model_h//2), + (cam_h//2, cam_w//2), 0, border_fill_val=border_fill).realize() + v = warp_perspective_tinygrad(uv[:cam_h//2, 1:cam_w:2].flatten(), + M_inv_uv, (model_w//2, model_h//2), + (cam_h//2, cam_w//2), 0, border_fill_val=border_fill).realize() + yuv = y.cat(u).cat(v).reshape((model_h * 3 // 2, model_w)) + return frames_to_tensor(yuv) + return frame_prepare_tinygrad + + +def make_warp(nv12, model_w, model_h, layout="yuv420", border_fill=None, frames=1): + frame_prepare = make_frame_prepare(nv12, model_w, model_h, layout, border_fill) + + def warp(input_frames, transforms): + input_frames = input_frames.to(Device.DEFAULT) + transforms = transforms.to(Device.DEFAULT) + Tensor.realize(input_frames, transforms) + if frames == 1: + return frame_prepare(input_frames, transforms) + return Tensor.stack(*(frame_prepare(input_frames[i], transforms[i]) for i in range(frames))) + + return warp + + +def _parse_size(s): + w, h = s.lower().split('x') + return int(w), int(h) + + +def compile_warp(nv12: NV12Frame, model_w, model_h, pkl_path, layout, border_fill=None, + frames=1, transform_device=None, frame_size=None): + print(f"Compiling {layout} warp for {nv12.width}x{nv12.height} -> {model_w}x{model_h}...") + + warp_jit = TinyJit(make_warp(nv12, model_w, model_h, layout, border_fill, frames), prune=True) + frame_size = nv12.size if frame_size is None else frame_size + frame_shape = (frame_size,) if frames == 1 else (frames, frame_size) + transform_shape = (3, 3) if frames == 1 else (frames, 3, 3) + + for i in range(10): + frame = Tensor.randint(*frame_shape, low=0, high=256, dtype='uint8').realize() + M_inv = Tensor(Tensor.randn(*transform_shape).mul(8).realize().numpy(), device=transform_device) + Device.default.synchronize() + st = time.perf_counter() + warp_jit(frame, M_inv).realize() + mt = time.perf_counter() + Device.default.synchronize() + et = time.perf_counter() + print(f" [{i+1}/10] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") + + with open(pkl_path, "wb") as f: + pickle.dump(warp_jit, f) + print(f" Saved to {pkl_path}") + + +if __name__ == "__main__": + p = argparse.ArgumentParser() + p.add_argument('--camera-resolution', type=_parse_size, required=True, help='camera resolution WxH') + p.add_argument('--warp-to', type=_parse_size, required=True, help='output WxH') + p.add_argument('--layout', choices=['luma', 'yuv420'], required=True) + p.add_argument('--border-fill', type=int, help='fill value outside the frame; omit to clamp coordinates') + p.add_argument('--frames', type=int, default=1, help='number of frames to warp together') + p.add_argument('--transform-device', help='device holding the input transforms; default: compute device') + p.add_argument('--frame-size', type=int, help='input frame size in bytes; default: full NV12 allocation') + p.add_argument('--output', required=True) + args = p.parse_args() + + cam_w, cam_h = args.camera_resolution + nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) + model_w, model_h = args.warp_to + compile_warp(nv12, model_w, model_h, args.output, args.layout, args.border_fill, + args.frames, args.transform_device, args.frame_size) diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 0fdd1629ba..a5b64d1b25 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -13,6 +13,21 @@ MODELS_DIR = Path(__file__).resolve().parent / 'models' TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json' +def patch_tinygrad_fetch_fw(): + import hashlib + import zstandard + from tinygrad import helpers + original_fetch_fw = helpers.fetch_fw + def fetch_fw(path, name, sha256): + p = Path(f"/lib/firmware/{path}/{name}.zst") + if p.is_file(): + blob = zstandard.ZstdDecompressor().stream_reader(p.read_bytes()).read() + if hashlib.sha256(blob).hexdigest() == sha256: + return blob + return original_fetch_fw(path, name, sha256) + helpers.fetch_fw = fetch_fw + + def get_tg_input_devices(process_name: str, chestnut: bool): with open(TG_INPUT_DEVICES_PATH) as f: return json.load(f)[process_name]['default' if not chestnut else 'chestnut'] diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 994fda8b35..fa9a61a8e8 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -4,7 +4,13 @@ import ctypes from functools import cached_property import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom -from tinygrad.device import Device +from tinygrad.device import Buffer, Device +from tinygrad.dtype import DType, dtypes +from tinygrad.tensor import Tensor +from tinygrad.helpers import round_up +from tinygrad.uop.ops import UOp +import math +import pickle import threading import time import numpy as np @@ -26,11 +32,11 @@ from openpilot.common.transformations.model import get_warp_matrix from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, should_stop, smooth_value, get_curvature_from_plan from openpilot.selfdrive.modeld.parse_model_outputs import Parser -from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, nv12_copy_size +from openpilot.selfdrive.modeld.compile_modeld import make_input_queues from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.constants import ModelConstants, Plan -from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob +from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') @@ -124,36 +130,59 @@ class FrameMeta: self.frame_id, self.timestamp_sof, self.timestamp_eof = vipc.frame_id, vipc.timestamp_sof, vipc.timestamp_eof +def input_view(buffer: Buffer, shape: tuple[int, ...], dtype: DType, offset: int) -> Tensor: + view = buffer.view(math.prod(shape), dtype, offset).ensure_allocated() + return Tensor(UOp.from_buffer(view)).reshape(shape) + + class ModelState: prev_desire: np.ndarray # for tracking the rising edge of the pulse def __init__(self, cam_w: int, cam_h: int, chestnut: bool): jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut))) - input_devices = jits['input_devices'] - self.model_device = input_devices['model'] - metadata = jits['metadata'] + self.model_device = jits['input_devices']['model'] self.input_shapes = jits['input_shapes'] self.state_pairs = jits['state_pairs'] self.vision_input_names = ('img', 'big_img') - self.output_slices = metadata['output_slices'] + self.output_slices = jits['metadata']['output_slices'] self.prev_desire = np.zeros(ModelConstants.DESIRE_LEN, dtype=np.float32) self.chestnut = chestnut - self.frame_copy_size = nv12_copy_size(*get_nv12_info(cam_w, cam_h)[:3]) - self.input_queues, self.npy, self.frame_views = make_input_queues( - self.input_shapes, self.state_pairs, device=self.model_device, frame_copy_size=self.frame_copy_size) + stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) + self.frame_copy_size = stride * (y_height + uv_height) + self.pack_inputs() + with open(MODELS_DIR / f'{"big_" if chestnut else ""}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl', 'rb') as f: + self.run_warp = pickle.load(f) + self.run_model = jits['run_model'] self.parser = Parser() - self.run_model = jits['run_model'][(cam_w,cam_h)] + + def pack_inputs(self) -> None: + # Pack host inputs into one upload to reduce USB transfer overhead for the eGPU. + self.input_queues = make_input_queues({name: self.input_shapes[name] for name in self.state_pairs}, self.model_device) + shapes = {'tfm': (2, 3, 3)} | {name: shape for name, (shape, _) in self.input_shapes.items() + if name not in self.state_pairs and name != 'new_img'} + npy_size = sum(round_up(math.prod(shape) * 4, 128) for shape in shapes.values()) + self.packed_input = np.zeros(npy_size + 2 * self.frame_copy_size, dtype=np.uint8) + self.input_host = Tensor(self.packed_input, device='NPY')._buffer() + self.input_device = Tensor(self.packed_input, device=self.model_device)._buffer() + self.npy = {} + offset = 0 + for name, shape in shapes.items(): + self.npy[name] = np.ndarray(shape, dtype=np.float32, buffer=self.packed_input, offset=offset) + self.input_queues[name] = input_view(self.input_device, shape, dtypes.float32, offset) + offset += round_up(self.npy[name].nbytes, 128) + self.frames = self.packed_input[npy_size:].reshape(2, self.frame_copy_size) + self.warp_inputs = (input_view(self.input_device, self.frames.shape, dtypes.uint8, npy_size), self.input_queues.pop('tfm')) def slice_outputs(self, model_outputs: np.ndarray, output_slices: dict[str, slice]) -> dict[str, np.ndarray]: - parsed_model_outputs = {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()} - return parsed_model_outputs + return {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()} def run(self, bufs: dict[str, VisionBuf], transforms: dict[str, np.ndarray], inputs: dict[str, np.ndarray], after_enqueue: Callable[[], None] | None = None) -> dict[str, np.ndarray]: - for key, buf in bufs.items(): - np.copyto(self.frame_views[key], np.frombuffer(buf.data, dtype=np.uint8, count=self.frame_copy_size)) + for i, key in enumerate(self.vision_input_names): + np.copyto(self.frames[i], np.frombuffer(bufs[key].data, dtype=np.uint8, count=self.frame_copy_size)) + self.npy['tfm'][i] = transforms[key] # Model decides when action is completed, so desire input is just a pulse triggered on rising edge inputs['desire_pulse'][0] = 0 @@ -161,9 +190,9 @@ class ModelState: self.prev_desire[:] = inputs['desire_pulse'] self.npy['traffic_convention'][:] = inputs['traffic_convention'] self.npy['action_t'][:] = inputs['action_t'] - self.npy['tfm'][:,:] = transforms['img'][:,:] - self.npy['big_tfm'][:,:] = transforms['big_img'][:,:] + self.input_device.copy_from(self.input_host) + self.input_queues['new_img'] = self.run_warp(*self.warp_inputs) outs, = self.run_model(**self.input_queues) if after_enqueue is not None: after_enqueue() @@ -181,8 +210,9 @@ class ModelState: eye = np.eye(3, dtype=np.float32) dims = {'desire_pulse': ModelConstants.DESIRE_LEN, 'traffic_convention': 2, 'action_t': 2} self.run(dummy_frames, dict.fromkeys(self.vision_input_names, eye), {k: np.zeros(v, dtype=np.float32) for k, v in dims.items()}) - self.input_queues, self.npy, self.frame_views = make_input_queues( - self.input_shapes, self.state_pairs, device=self.model_device, frame_copy_size=self.frame_copy_size) + self.packed_input[:] = 0 + for key in self.state_pairs: + self.input_queues[key].assign(0).realize() self.prev_desire[:] = 0 From b751b04cdad7dde9c738ede3cab79fa673d15541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Tue, 15 Sep 2026 11:21:47 -0700 Subject: [PATCH 119/270] Use tinygrad ONNX and warp compilers for driving and DM (#38922) * modeld: use tinygrad ONNX and warp compilers for driving and DM * Bump tinygrad with current openpilot model compile tests * modeld: prioritize tinygrad compiler imports in build environment * Use compiler branch based on openpilot's pinned tinygrad * modeld: invoke tinygrad compilers without firmware patch --- .gitmodules | 3 +- openpilot/selfdrive/modeld/SConscript | 112 ++++--------- openpilot/selfdrive/modeld/compile_modeld.py | 117 -------------- openpilot/selfdrive/modeld/compile_warp.py | 150 ------------------ .../selfdrive/modeld/dmonitoringmodeld.py | 22 +-- .../selfdrive/modeld/get_model_metadata.py | 55 ------- openpilot/selfdrive/modeld/helpers.py | 38 ----- openpilot/selfdrive/modeld/modeld.py | 4 +- tinygrad_repo | 2 +- 9 files changed, 50 insertions(+), 453 deletions(-) delete mode 100755 openpilot/selfdrive/modeld/compile_modeld.py delete mode 100644 openpilot/selfdrive/modeld/compile_warp.py delete mode 100755 openpilot/selfdrive/modeld/get_model_metadata.py diff --git a/.gitmodules b/.gitmodules index ad6530de9a..7e37596b0a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -15,4 +15,5 @@ url = ../../commaai/teleoprtc [submodule "tinygrad"] path = tinygrad_repo - url = https://github.com/tinygrad/tinygrad.git + url = https://github.com/commaai/tinygrad.git + branch = openpilot-modeld-test diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 6af7f09092..6ccf711c4c 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -1,18 +1,18 @@ import glob -import json import os import time from SCons.Script import Action, Value from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_existing_chunks from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE -from openpilot.selfdrive.modeld.helpers import TG_INPUT_DEVICES_PATH, chestnut_present, modeld_pkl_path +from openpilot.selfdrive.modeld.helpers import chestnut_present, modeld_pkl_path from openpilot.system.camerad.cameras.nv12_info import get_nv12_info Import('env', 'arch') chunker_file = File("#openpilot/common/file_chunker.py") lenv = env.Clone() +lenv.PrependENVPath('PYTHONPATH', Dir('#tinygrad_repo').abspath) tinygrad_root = env.Dir("#").abspath tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=tinygrad_root) @@ -26,62 +26,27 @@ def estimate_pickle_max_size(onnx_size): camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)] if arch == 'comma_arm64': - tg_backend = 'QCOM' - tg_flags = f'DEV={tg_backend} IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' + tg_flags = 'DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' else: - tg_backend = 'METAL' if arch == 'Darwin' else 'CPU' # JIT=2 disables graph batching, which produces incorrect outputs after buffers change. tg_flags = 'DEV=METAL JIT=2' if arch == 'Darwin' else 'DEV=CPU:LLVM' -tg_devices = { # which device to put jit inputs to at runtime - 'openpilot.selfdrive.modeld.dmonitoringmodeld': { - 'default': {'DEV': tg_backend} - }, -} - CHESTNUT = chestnut_present() if CHESTNUT: chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32' # the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it chestnut_lock = File("models/.chestnut.lock").abspath -def write_tg_devices(target, source, env): - with open(str(target[0]), "w") as f: - json.dump(tg_devices, f) - f.write("\n") - -tg_devices_node = lenv.Command( - str(TG_INPUT_DEVICES_PATH), - [Value(tg_devices)], - write_tg_devices, -) - # tinygrad calls brew which needs a $HOME in the env mac_brew_string = f'HOME={os.path.expanduser("~")}' if arch == 'Darwin' else '' -modeld_dir = Dir("#openpilot/selfdrive/modeld").abspath -compile_modeld_script = [ - File(f"{modeld_dir}/compile_modeld.py"), - File(f"{modeld_dir}/get_model_metadata.py"), - File(f"{modeld_dir}/helpers.py"), - File("#openpilot/common/hardware/hw.py"), -] -compile_warp_script = [File(f"{modeld_dir}/compile_warp.py"), File(f"{modeld_dir}/helpers.py"), - File("#openpilot/system/camerad/cameras/nv12_info.py")] -model_w, model_h = MEDMODEL_INPUT_SIZE +warp_deps = [File("#openpilot/system/camerad/cameras/nv12_info.py")] +compiler = 'python3 -m examples.openpilot' +# CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. +taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' -for chestnut in [False, True] if CHESTNUT else [False]: - target_pkl_path = File(modeld_pkl_path(chestnut)).abspath - file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) - driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath) - # CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. - taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' - cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_modeld.py ' - f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} ' - f'--output {target_pkl_path}') - onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps) - chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) - def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets): +def chestnut_action(command, pkl=None, chunks=()): + def do_compile(target, source, env): from openpilot.system.hardware.chestnut.flash import link_up # chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars for _ in range(10): @@ -95,58 +60,49 @@ for chestnut in [False, True] if CHESTNUT else [False]: return ret if chunks: chunk_file(pkl, chunks) + return Action(do_compile, " [CHESTNUT] $TARGET") + +def compile_model(onnx_path, pkl_path, flags, chestnut=False): + onnx_path, target_pkl_path = File(onnx_path).abspath, File(pkl_path).abspath + onnx_deps = get_existing_chunks(onnx_path) + cmd = (f'{flags} {mac_brew_string} {taskset}{compiler}.compile_onnx ' + f'--onnx {onnx_path} --output {target_pkl_path}') + onnx_sizes_sum = sum(os.path.getsize(f) for f in onnx_deps) + chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets): chunk_file(pkl, chunks) - actions = Action(do_compile, " [CHESTNUT] $TARGET") if chestnut else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] + actions = chestnut_action(cmd, target_pkl_path, chunk_targets) if chestnut else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] node = lenv.Command( chunk_targets, - tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(cmd), Value(chunk_targets), chunker_file], + tinygrad_files + onnx_deps + [Value(cmd), Value(chunk_targets), chunker_file], actions, ) if chestnut: lenv.SideEffect(chestnut_lock, node) +compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygrad.pkl', tg_flags) + +model_w, model_h = MEDMODEL_INPUT_SIZE +for chestnut in [False, True] if CHESTNUT else [False]: + file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) + compile_model(f'models/{file_prefix}driving_supercombo.onnx', modeld_pkl_path(chestnut), cmd_flags, chestnut) for cam_w, cam_h in camera_configs: warp_pkl_path = File(f"models/{file_prefix}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) - cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_warp.py ' + cmd = (f'{cmd_flags} {mac_brew_string} {taskset}{compiler}.compile_warp ' f'--camera-resolution {cam_w}x{cam_h} --warp-to {model_w}x{model_h} --layout yuv420 ' - f'--frames 2 --frame-size {stride * (y_height + uv_height)} ' + f'--frames 2 --stride {stride} --uv-offset {stride * y_height} --frame-size {stride * (y_height + uv_height)} ' f'--output {warp_pkl_path}') - def do_compile_warp(target, source, env, command=cmd): - return do_compile(target, source, env, command=command, chunks=()) - action = Action(do_compile_warp, " [CHESTNUT] $TARGET") if chestnut else cmd - node = lenv.Command(warp_pkl_path, tinygrad_files + compile_warp_script + [Value(cmd)], action) + action = chestnut_action(cmd) if chestnut else cmd + node = lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], action) if chestnut: lenv.SideEffect(chestnut_lock, node) -# get model metadata -fn = File(f"models/dmonitoring_model").abspath -script_files = [File(Dir("#openpilot/selfdrive/modeld").File("get_model_metadata.py").abspath)] -cmd = f'{tg_flags} {mac_brew_string} python3 {Dir("#openpilot/selfdrive/modeld").abspath}/get_model_metadata.py {fn}.onnx' -lenv.Command(fn + "_metadata.pkl", [fn + ".onnx"] + tinygrad_files + script_files + [tg_devices_node], cmd) - dm_w, dm_h = DM_INPUT_SIZE for cam_w, cam_h in camera_configs: dm_pkl_path = File(f"models/dm_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath - cmd = (f'{tg_flags} {mac_brew_string} python3 {modeld_dir}/compile_warp.py ' + stride, y_height, _, frame_size = get_nv12_info(cam_w, cam_h) + cmd = (f'{tg_flags} {mac_brew_string} {compiler}.compile_warp ' f'--camera-resolution {cam_w}x{cam_h} --warp-to {dm_w}x{dm_h} --layout luma --border-fill 16 --transform-device NPY ' - f'--output {dm_pkl_path}') - lenv.Command(dm_pkl_path, tinygrad_files + compile_warp_script + [tg_devices_node], cmd) - -def tg_compile(flags, model_name): - pythonpath_string = 'PYTHONPATH="${PYTHONPATH}:' + env.Dir("#tinygrad_repo").abspath + '"' - fn = File(f"models/{model_name}").abspath - pkl = fn + "_tinygrad.pkl" - onnx_path = fn + ".onnx" - chunk_targets = get_chunk_targets(pkl, estimate_pickle_max_size(os.path.getsize(onnx_path))) - def do_chunk(target, source, env): - chunk_file(pkl, chunk_targets) - return lenv.Command( - chunk_targets, - [onnx_path] + tinygrad_files + [Value(chunk_targets), chunker_file, tg_devices_node], - [f'{pythonpath_string} {flags} python3 {Dir("#tinygrad_repo").abspath}/examples/openpilot/compile3.py {fn}.onnx {pkl}', - Action(do_chunk, " [CHUNK] $TARGET")], - ) - -tg_compile(tg_flags, 'dmonitoring_model') + f'--stride {stride} --uv-offset {stride * y_height} --frame-size {frame_size} --output {dm_pkl_path}') + lenv.Command(dm_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], cmd) diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py deleted file mode 100755 index 463e697519..0000000000 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import atexit -import os -import tempfile -import time -import shutil - -import numpy as np - -from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob, patch_tinygrad_fetch_fw -patch_tinygrad_fetch_fw() - -from tinygrad.tensor import Tensor -from tinygrad.device import Device -from tinygrad.engine.jit import TinyJit - - -def make_input_queues(input_shapes, device): - return {name: Tensor(np.zeros(shape, dtype=dtype.fmt), device=device).realize() for name, (shape, dtype) in input_shapes.items()} - - -def make_run_model(model_runner, state_pairs): - def run_model(**inputs): - outputs = {name: value.contiguous() for name, value in model_runner(inputs).items()} - Tensor.realize(*outputs.values()) - if state_pairs: - Tensor.realize(*(inputs[name].assign(outputs[next_name]) for name, next_name in state_pairs.items())) - return tuple(value for name, value in outputs.items() if name not in state_pairs.values()) - return run_model - - -def compile_jit(jit, input_shapes, benchmark_runs): - if benchmark_runs < 1: - raise ValueError("benchmark_runs must be at least 1") - - SEED = 42 - def random_inputs_run(fn, seed, n_runs, test_val=None, test_buffers=None, expect_match=True): - input_queues = make_input_queues(input_shapes, Device.DEFAULT) - rng = np.random.default_rng(seed) - - for i in range(n_runs): - for value in input_queues.values(): - values = rng.standard_normal(value.shape) if np.issubdtype(np.dtype(value.dtype.fmt), np.floating) else rng.integers(0, 256, value.shape) - value.assign(Tensor(values.astype(value.dtype.fmt), device=Device.DEFAULT)).realize() - Device.default.synchronize() - st = time.perf_counter() - outs = fn(**input_queues) - mt = time.perf_counter() - Device.default.synchronize() - et = time.perf_counter() - print(f" [{i+1}/{n_runs}] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") - - if i == 0: - val = [v.numpy() for v in outs] - buffers = [v.numpy() for v in input_queues.values()] - - if test_val is not None: - match = all(np.array_equal(a, b) for a, b in zip(val, test_val, strict=True)) - assert match == expect_match, f"outputs {'differ from' if expect_match else 'match'} baseline (seed={seed})" - if test_buffers is not None: - match = all(np.array_equal(a, b) for a, b in zip(buffers, test_buffers, strict=True)) - assert match == expect_match, f"buffers {'differ from' if expect_match else 'match'} baseline (seed={seed})" - return val, buffers - - print('capture + replay') - test_val, test_buffers = random_inputs_run(jit, SEED, 3) - print(f'pickle round trip ({benchmark_runs} runs per seed)') - with tempfile.TemporaryFile(dir=".") as f: - dump_oob(jit, f) - f.seek(0) - loaded_jit = load_oob(f) - random_inputs_run(loaded_jit, SEED, benchmark_runs, test_val, test_buffers, expect_match=True) - random_inputs_run(loaded_jit, SEED+1, benchmark_runs, test_val, test_buffers, expect_match=False) - return jit - - -def read_file_chunked_to_disk(path): - from openpilot.common.file_chunker import open_file_chunked - tmp_path = f'{path}.unchunked' - with open(tmp_path, 'wb') as f, open_file_chunked(path) as src: - shutil.copyfileobj(src, f) - atexit.register(lambda: os.path.exists(tmp_path) and os.remove(tmp_path)) - return tmp_path - - -if __name__ == "__main__": - from tinygrad.nn.onnx import OnnxRunner - from openpilot.selfdrive.modeld.get_model_metadata import make_metadata_dict - p = argparse.ArgumentParser() - p.add_argument('--onnx', required=True) - p.add_argument('--output', required=True) - p.add_argument('--benchmark-runs', type=int, default=1, - help='timed loaded-JIT runs for each correctness seed') - args = p.parse_args() - - model_path = read_file_chunked_to_disk(args.onnx) - - model_runner = OnnxRunner(model_path) - input_shapes = {name: (spec.shape, spec.dtype) for name, spec in model_runner.graph_inputs.items()} - state_pairs = {name: f'next_{name}' for name in input_shapes if f'next_{name}' in model_runner.graph_outputs} - out = { - 'metadata': make_metadata_dict(model_path), - 'input_shapes': input_shapes, - 'state_pairs': state_pairs, - 'input_devices': {'model': Device.DEFAULT}, - } - - run_model = make_run_model(model_runner, state_pairs) - out['run_model'] = compile_jit(TinyJit(run_model, prune=True), input_shapes, args.benchmark_runs) - - with open(args.output, "wb") as f: - dump_oob(out, f) - with open(args.output, "rb") as f: - load_oob(f) - assert not f.read(1), "unexpected model buffer data" - print(f"Saved JITs to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB)") diff --git a/openpilot/selfdrive/modeld/compile_warp.py b/openpilot/selfdrive/modeld/compile_warp.py deleted file mode 100644 index 1b2c085537..0000000000 --- a/openpilot/selfdrive/modeld/compile_warp.py +++ /dev/null @@ -1,150 +0,0 @@ -import argparse -import pickle -import time -from collections import namedtuple - -from openpilot.selfdrive.modeld.helpers import patch_tinygrad_fetch_fw -patch_tinygrad_fetch_fw() - -from tinygrad.tensor import Tensor -from tinygrad.helpers import Context -from tinygrad.device import Device -from tinygrad.engine.jit import TinyJit - -from openpilot.system.camerad.cameras.nv12_info import get_nv12_info - - -NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size']) - - -def warp_perspective_tinygrad(src_flat, M_inv, dst_shape, src_shape, stride_pad, border_fill_val=None): - w_dst, h_dst = dst_shape - h_src, w_src = src_shape - - x = Tensor.arange(w_dst).reshape(1, w_dst).expand(h_dst, w_dst).reshape(-1) - y = Tensor.arange(h_dst).reshape(h_dst, 1).expand(h_dst, w_dst).reshape(-1) - - # inline 3x3 matmul as elementwise to avoid reduce op (enables fusion with gather) - src_x = M_inv[0, 0] * x + M_inv[0, 1] * y + M_inv[0, 2] - src_y = M_inv[1, 0] * x + M_inv[1, 1] * y + M_inv[1, 2] - src_w = M_inv[2, 0] * x + M_inv[2, 1] * y + M_inv[2, 2] - - src_x = src_x / src_w - src_y = src_y / src_w - - x_round = Tensor.round(src_x) - y_round = Tensor.round(src_y) - x_nn_clipped = x_round.clip(0, w_src - 1).cast('int') - y_nn_clipped = y_round.clip(0, h_src - 1).cast('int') - idx = y_nn_clipped * (w_src + stride_pad) + x_nn_clipped - sampled = src_flat[idx] - - if border_fill_val is None: - return sampled - - in_bounds = ((x_round >= 0) & (x_round <= w_src - 1) & - (y_round >= 0) & (y_round <= h_src - 1)).cast(sampled.dtype) - return sampled * in_bounds + Tensor(border_fill_val, dtype=sampled.dtype) * (1 - in_bounds) - - -def frames_to_tensor(frames): - H = (frames.shape[0] * 2) // 3 - W = frames.shape[1] - return Tensor.cat(frames[0:H:2, 0::2], - frames[1:H:2, 0::2], - frames[0:H:2, 1::2], - frames[1:H:2, 1::2], - frames[H:H+H//4].reshape((H//2, W//2)), - frames[H+H//4:H+H//2].reshape((H//2, W//2)), dim=0).reshape((6, H//2, W//2)) - - -def make_frame_prepare(nv12: NV12Frame, model_w, model_h, layout="yuv420", border_fill=None): - cam_w, cam_h, stride, y_height, uv_height, _ = nv12 - uv_offset = stride * y_height - stride_pad = stride - cam_w - - def frame_prepare_tinygrad(input_frame, M_inv): - if layout == "luma": - return warp_perspective_tinygrad(input_frame[:cam_h*stride], M_inv, - (model_w, model_h), (cam_h, cam_w), stride_pad, - border_fill_val=border_fill).reshape(-1, model_h * model_w) - # UV_SCALE @ M_inv @ UV_SCALE_INV simplifies to elementwise scaling - M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=Device.DEFAULT) - # deinterleave NV12 UV plane (UVUV... -> separate U, V) - uv = input_frame[uv_offset:uv_offset + uv_height * stride].reshape(uv_height, stride) - with Context(SPLIT_REDUCEOP=0): - y = warp_perspective_tinygrad(input_frame[:cam_h*stride], - M_inv, (model_w, model_h), - (cam_h, cam_w), stride_pad, border_fill_val=border_fill).realize() - u = warp_perspective_tinygrad(uv[:cam_h//2, :cam_w:2].flatten(), - M_inv_uv, (model_w//2, model_h//2), - (cam_h//2, cam_w//2), 0, border_fill_val=border_fill).realize() - v = warp_perspective_tinygrad(uv[:cam_h//2, 1:cam_w:2].flatten(), - M_inv_uv, (model_w//2, model_h//2), - (cam_h//2, cam_w//2), 0, border_fill_val=border_fill).realize() - yuv = y.cat(u).cat(v).reshape((model_h * 3 // 2, model_w)) - return frames_to_tensor(yuv) - return frame_prepare_tinygrad - - -def make_warp(nv12, model_w, model_h, layout="yuv420", border_fill=None, frames=1): - frame_prepare = make_frame_prepare(nv12, model_w, model_h, layout, border_fill) - - def warp(input_frames, transforms): - input_frames = input_frames.to(Device.DEFAULT) - transforms = transforms.to(Device.DEFAULT) - Tensor.realize(input_frames, transforms) - if frames == 1: - return frame_prepare(input_frames, transforms) - return Tensor.stack(*(frame_prepare(input_frames[i], transforms[i]) for i in range(frames))) - - return warp - - -def _parse_size(s): - w, h = s.lower().split('x') - return int(w), int(h) - - -def compile_warp(nv12: NV12Frame, model_w, model_h, pkl_path, layout, border_fill=None, - frames=1, transform_device=None, frame_size=None): - print(f"Compiling {layout} warp for {nv12.width}x{nv12.height} -> {model_w}x{model_h}...") - - warp_jit = TinyJit(make_warp(nv12, model_w, model_h, layout, border_fill, frames), prune=True) - frame_size = nv12.size if frame_size is None else frame_size - frame_shape = (frame_size,) if frames == 1 else (frames, frame_size) - transform_shape = (3, 3) if frames == 1 else (frames, 3, 3) - - for i in range(10): - frame = Tensor.randint(*frame_shape, low=0, high=256, dtype='uint8').realize() - M_inv = Tensor(Tensor.randn(*transform_shape).mul(8).realize().numpy(), device=transform_device) - Device.default.synchronize() - st = time.perf_counter() - warp_jit(frame, M_inv).realize() - mt = time.perf_counter() - Device.default.synchronize() - et = time.perf_counter() - print(f" [{i+1}/10] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") - - with open(pkl_path, "wb") as f: - pickle.dump(warp_jit, f) - print(f" Saved to {pkl_path}") - - -if __name__ == "__main__": - p = argparse.ArgumentParser() - p.add_argument('--camera-resolution', type=_parse_size, required=True, help='camera resolution WxH') - p.add_argument('--warp-to', type=_parse_size, required=True, help='output WxH') - p.add_argument('--layout', choices=['luma', 'yuv420'], required=True) - p.add_argument('--border-fill', type=int, help='fill value outside the frame; omit to clamp coordinates') - p.add_argument('--frames', type=int, default=1, help='number of frames to warp together') - p.add_argument('--transform-device', help='device holding the input transforms; default: compute device') - p.add_argument('--frame-size', type=int, help='input frame size in bytes; default: full NV12 allocation') - p.add_argument('--output', required=True) - args = p.parse_args() - - cam_w, cam_h = args.camera_resolution - nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - model_w, model_h = args.warp_to - compile_warp(nv12, model_w, model_h, args.output, args.layout, args.border_fill, - args.frames, args.transform_device, args.frame_size) diff --git a/openpilot/selfdrive/modeld/dmonitoringmodeld.py b/openpilot/selfdrive/modeld/dmonitoringmodeld.py index 4010725b89..4d84aa0cd6 100755 --- a/openpilot/selfdrive/modeld/dmonitoringmodeld.py +++ b/openpilot/selfdrive/modeld/dmonitoringmodeld.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 import os -from openpilot.selfdrive.modeld.helpers import MODELS_DIR, get_tg_input_devices +from openpilot.selfdrive.modeld.helpers import MODELS_DIR, load_oob from tinygrad.tensor import Tensor import time import pickle @@ -18,10 +18,8 @@ from openpilot.system.camerad.cameras.nv12_info import get_nv12_info from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.parse_model_outputs import sigmoid, safe_exp -PROCESS_NAME = "openpilot.selfdrive.modeld.dmonitoringmodeld" SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') MODEL_PKL_PATH = MODELS_DIR / 'dmonitoring_model_tinygrad.pkl' -METADATA_PATH = MODELS_DIR / 'dmonitoring_model_metadata.pkl' class ModelState: @@ -29,11 +27,10 @@ class ModelState: output: np.ndarray def __init__(self, cam_w: int, cam_h: int): - self.DEV = get_tg_input_devices(PROCESS_NAME, chestnut=False)['DEV'] - with open(METADATA_PATH, 'rb') as f: - model_metadata = pickle.load(f) - self.input_shapes = model_metadata['input_shapes'] - self.output_slices = model_metadata['output_slices'] + jits = load_oob(open_file_chunked(MODEL_PKL_PATH)) + self.DEV = jits['input_devices']['model'] + self.input_shapes = jits['metadata']['input_shapes'] + self.output_slices = jits['metadata']['output_slices'] self.numpy_inputs = { 'calib': np.zeros(self.input_shapes['calib'], dtype=np.float32), @@ -42,9 +39,10 @@ class ModelState: self.warp_inputs_np = {'transform': np.zeros((3,3), dtype=np.float32)} self.warp_inputs = {k: Tensor(v, device='NPY') for k,v in self.warp_inputs_np.items()} self.frame_buf_params = get_nv12_info(cam_w, cam_h) - self.tensor_inputs = {k: Tensor(v, device='NPY').realize() for k,v in self.numpy_inputs.items()} + self.tensor_inputs = {k: Tensor(v, device=self.DEV).realize() for k,v in self.numpy_inputs.items()} + self.calib_host = Tensor(self.numpy_inputs['calib'], device='NPY')._buffer() self._blob_cache : dict[int, Tensor] = {} - self.model_run = pickle.load(open_file_chunked(str(MODEL_PKL_PATH))) + self.model_run = jits['run_model'] with open(MODELS_DIR / f'dm_warp_{cam_w}x{cam_h}_tinygrad.pkl', "rb") as f: self.image_warp = pickle.load(f) @@ -52,6 +50,7 @@ class ModelState: self.numpy_inputs['calib'][0,:] = calib t1 = time.perf_counter() + self.tensor_inputs['calib']._buffer().copy_from(self.calib_host) ptr = np.frombuffer(buf.data, dtype=np.uint8).ctypes.data # There is a ringbuffer of imgs, just cache tensors pointing to all of them @@ -61,7 +60,8 @@ class ModelState: self.warp_inputs_np['transform'][:] = transform[:] self.tensor_inputs['input_img'] = self.image_warp(self._blob_cache[ptr], self.warp_inputs['transform']) - output = self.model_run(**self.tensor_inputs).numpy().flatten() + output, = self.model_run(**self.tensor_inputs) + output = output.numpy().astype(np.float32).reshape(-1) t2 = time.perf_counter() return output, t2 - t1 diff --git a/openpilot/selfdrive/modeld/get_model_metadata.py b/openpilot/selfdrive/modeld/get_model_metadata.py deleted file mode 100755 index e4c173957a..0000000000 --- a/openpilot/selfdrive/modeld/get_model_metadata.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -import sys -import pathlib -import codecs -import pickle -from typing import Any - -from tinygrad.nn.onnx import OnnxPBParser - - -class MetadataOnnxPBParser(OnnxPBParser): - def _parse_ModelProto(self) -> dict: - obj: dict[str, Any] = {"graph": {"input": [], "output": []}, "metadata_props": []} - for fid, wire_type in self._parse_message(self.reader.len): - match fid: - case 7: - obj["graph"] = self._parse_GraphProto() - case 14: - obj["metadata_props"].append(self._parse_StringStringEntryProto()) - case _: - self.reader.skip_field(wire_type) - return obj - - -def get_name_and_shape(value_info: dict[str, Any]) -> tuple[str, tuple[int, ...]]: - shape = tuple(int(dim) if isinstance(dim, int) else 0 for dim in value_info["parsed_type"].shape) - name = value_info["name"] - return name, shape - - -def get_metadata_value_by_name(model: dict[str, Any], name: str) -> str | Any: - for prop in model["metadata_props"]: - if prop["key"] == name: - return prop["value"] - return None - - -def make_metadata_dict(model_path): - model = MetadataOnnxPBParser(model_path).parse() - output_slices = get_metadata_value_by_name(model, 'output_slices') - assert output_slices is not None, 'output_slices not found in metadata' - return { - 'model_checkpoint': get_metadata_value_by_name(model, 'model_checkpoint'), - 'output_slices': pickle.loads(codecs.decode(output_slices.encode(), "base64")), - 'input_shapes': dict(get_name_and_shape(x) for x in model["graph"]["input"]), - 'output_shapes': dict(get_name_and_shape(x) for x in model["graph"]["output"]), - } - - -if __name__ == "__main__": - model_path = pathlib.Path(sys.argv[1]) - metadata_path = model_path.parent / (model_path.stem + '_metadata.pkl') - with open(metadata_path, 'wb') as f: - pickle.dump(make_metadata_dict(model_path), f) - print(f'saved metadata to {metadata_path}') diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index a5b64d1b25..307cb7c163 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -1,56 +1,18 @@ import io -import json import pickle -import shutil import struct -import tempfile from pathlib import Path from openpilot.common.file_chunker import get_manifest_path from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH, is_chestnut_usb_id MODELS_DIR = Path(__file__).resolve().parent / 'models' -TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json' -def patch_tinygrad_fetch_fw(): - import hashlib - import zstandard - from tinygrad import helpers - original_fetch_fw = helpers.fetch_fw - def fetch_fw(path, name, sha256): - p = Path(f"/lib/firmware/{path}/{name}.zst") - if p.is_file(): - blob = zstandard.ZstdDecompressor().stream_reader(p.read_bytes()).read() - if hashlib.sha256(blob).hexdigest() == sha256: - return blob - return original_fetch_fw(path, name, sha256) - helpers.fetch_fw = fetch_fw - - -def get_tg_input_devices(process_name: str, chestnut: bool): - with open(TG_INPUT_DEVICES_PATH) as f: - return json.load(f)[process_name]['default' if not chestnut else 'chestnut'] - def modeld_pkl_path(chestnut: bool): prefix = 'big_' if chestnut else '' return MODELS_DIR / f'{prefix}driving_tinygrad.pkl' -def dump_oob(obj, f): - with tempfile.TemporaryFile(dir=".") as tmp: - def buffer_callback(pb: pickle.PickleBuffer): - m = pb.raw() - tmp.write(struct.pack(' None: # Pack host inputs into one upload to reduce USB transfer overhead for the eGPU. - self.input_queues = make_input_queues({name: self.input_shapes[name] for name in self.state_pairs}, self.model_device) + self.input_queues = {name: Tensor(np.zeros(shape, dtype=dtype.fmt), device=self.model_device).realize() + for name, (shape, dtype) in self.input_shapes.items() if name in self.state_pairs} shapes = {'tfm': (2, 3, 3)} | {name: shape for name, (shape, _) in self.input_shapes.items() if name not in self.state_pairs and name != 'new_img'} npy_size = sum(round_up(math.prod(shape) * 4, 128) for shape in shapes.values()) diff --git a/tinygrad_repo b/tinygrad_repo index f6fc4e3f2c..953a7f36cf 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit f6fc4e3f2c3db5fae1e19cbfbc3ad9fc579a12ae +Subproject commit 953a7f36cfda50db17ee94505143474061c12e9d From f15544b48263a8ce91f1ebdb13787b0f9efd7710 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 15 Sep 2026 13:20:49 -0700 Subject: [PATCH 120/270] rm pyserial from updater bundle (#38925) rm pyserial dependency from updater package --- openpilot/common/hardware/comma/updater | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/common/hardware/comma/updater b/openpilot/common/hardware/comma/updater index 44b82d0c54..35227d2d79 100755 --- a/openpilot/common/hardware/comma/updater +++ b/openpilot/common/hardware/comma/updater @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a94ab8395f20d20a9d5a2a2bacca0694f072df8421cf13adca6250d28065bdc -size 24709205 +oid sha256:6a7adb302d378dda7b1788a841b89e4f905c872550a70a76650dcde977b4ece0 +size 24709209 From 81ae1a2e2dbd7147544732acca32c4db9cf10b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Tue, 15 Sep 2026 18:01:25 -0700 Subject: [PATCH 121/270] Use tinygrad generic ONNX compiler artifacts (#38926) * Use tinygrad generic ONNX compiler artifacts * Keep compiler temporary model buffers off device tmpfs * Keep recurrent model state updates on the GPU * Use compiled output buffers and swap recurrent state inputs * Note when ONNX chunk reassembly can be removed * Bump tinygrad to rebased compilers and AMD queue fix * Keep recurrent buffers fixed and use faster USB argument updates * Bump tinygrad for faster model startup --- .gitmodules | 2 +- openpilot/selfdrive/modeld/SConscript | 35 ++++++++++++------- .../selfdrive/modeld/dmonitoringmodeld.py | 16 +++++---- openpilot/selfdrive/modeld/modeld.py | 27 ++++++++------ tinygrad_repo | 2 +- 5 files changed, 50 insertions(+), 32 deletions(-) diff --git a/.gitmodules b/.gitmodules index 7e37596b0a..15ab89d49c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -16,4 +16,4 @@ [submodule "tinygrad"] path = tinygrad_repo url = https://github.com/commaai/tinygrad.git - branch = openpilot-modeld-test + branch = model-warp-compile diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 6ccf711c4c..c42e9f2335 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -1,8 +1,10 @@ import glob import os +import shutil +import tempfile import time from SCons.Script import Action, Value -from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_existing_chunks +from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_existing_chunks, open_file_chunked from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE from openpilot.selfdrive.modeld.helpers import chestnut_present, modeld_pkl_path @@ -41,7 +43,7 @@ if CHESTNUT: mac_brew_string = f'HOME={os.path.expanduser("~")}' if arch == 'Darwin' else '' warp_deps = [File("#openpilot/system/camerad/cameras/nv12_info.py")] -compiler = 'python3 -m examples.openpilot' +compiler = Dir('#tinygrad_repo/examples/openpilot').abspath # CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' @@ -65,13 +67,22 @@ def chestnut_action(command, pkl=None, chunks=()): def compile_model(onnx_path, pkl_path, flags, chestnut=False): onnx_path, target_pkl_path = File(onnx_path).abspath, File(pkl_path).abspath onnx_deps = get_existing_chunks(onnx_path) - cmd = (f'{flags} {mac_brew_string} {taskset}{compiler}.compile_onnx ' - f'--onnx {onnx_path} --output {target_pkl_path}') + cmd = (f'{flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_onnx.py" ' + f'"{{onnx}}" "{target_pkl_path}" --device-input "*" --out-of-band --benchmark-runs 1') + def do_compile(target, source, env): + if os.path.isfile(onnx_path): + return env.Execute(cmd.format(onnx=onnx_path)) + # TODO: Remove ONNX chunk reassembly once models are precompiled. + with tempfile.NamedTemporaryFile(dir=os.path.dirname(onnx_path), suffix='.onnx') as tmp, open_file_chunked(onnx_path) as src: + shutil.copyfileobj(src, tmp) + tmp.flush() + return env.Execute(cmd.format(onnx=tmp.name)) + compile_action = Action(do_compile, " [ONNX] $TARGET") onnx_sizes_sum = sum(os.path.getsize(f) for f in onnx_deps) chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets): chunk_file(pkl, chunks) - actions = chestnut_action(cmd, target_pkl_path, chunk_targets) if chestnut else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] + actions = chestnut_action(compile_action, target_pkl_path, chunk_targets) if chestnut else [compile_action, Action(do_chunk, " [CHUNK] $TARGET")] node = lenv.Command( chunk_targets, tinygrad_files + onnx_deps + [Value(cmd), Value(chunk_targets), chunker_file], @@ -89,9 +100,9 @@ for chestnut in [False, True] if CHESTNUT else [False]: for cam_w, cam_h in camera_configs: warp_pkl_path = File(f"models/{file_prefix}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) - cmd = (f'{cmd_flags} {mac_brew_string} {taskset}{compiler}.compile_warp ' - f'--camera-resolution {cam_w}x{cam_h} --warp-to {model_w}x{model_h} --layout yuv420 ' - f'--frames 2 --stride {stride} --uv-offset {stride * y_height} --frame-size {stride * (y_height + uv_height)} ' + cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_warp.py" ' + f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{stride * (y_height + uv_height)} ' + f'--warp-to {model_w}x{model_h} --layout yuv420 --frames 2 ' f'--output {warp_pkl_path}') action = chestnut_action(cmd) if chestnut else cmd node = lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], action) @@ -101,8 +112,8 @@ for chestnut in [False, True] if CHESTNUT else [False]: dm_w, dm_h = DM_INPUT_SIZE for cam_w, cam_h in camera_configs: dm_pkl_path = File(f"models/dm_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath - stride, y_height, _, frame_size = get_nv12_info(cam_w, cam_h) - cmd = (f'{tg_flags} {mac_brew_string} {compiler}.compile_warp ' - f'--camera-resolution {cam_w}x{cam_h} --warp-to {dm_w}x{dm_h} --layout luma --border-fill 16 --transform-device NPY ' - f'--stride {stride} --uv-offset {stride * y_height} --frame-size {frame_size} --output {dm_pkl_path}') + stride, y_height, uv_height, frame_size = get_nv12_info(cam_w, cam_h) + cmd = (f'{tg_flags} {mac_brew_string} python3 "{compiler}/compile_warp.py" ' + f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{frame_size} --warp-to {dm_w}x{dm_h} ' + f'--layout luma --border-fill 16 --transform-device NPY --output {dm_pkl_path}') lenv.Command(dm_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], cmd) diff --git a/openpilot/selfdrive/modeld/dmonitoringmodeld.py b/openpilot/selfdrive/modeld/dmonitoringmodeld.py index 4d84aa0cd6..06d0d2e5e7 100755 --- a/openpilot/selfdrive/modeld/dmonitoringmodeld.py +++ b/openpilot/selfdrive/modeld/dmonitoringmodeld.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import os +import base64 from openpilot.selfdrive.modeld.helpers import MODELS_DIR, load_oob from tinygrad.tensor import Tensor import time @@ -28,9 +29,9 @@ class ModelState: def __init__(self, cam_w: int, cam_h: int): jits = load_oob(open_file_chunked(MODEL_PKL_PATH)) - self.DEV = jits['input_devices']['model'] + self.DEV = jits['input_specs']['input_img'][2] self.input_shapes = jits['metadata']['input_shapes'] - self.output_slices = jits['metadata']['output_slices'] + self.output_slices = pickle.loads(base64.b64decode(jits['metadata']['metadata']['output_slices'])) self.numpy_inputs = { 'calib': np.zeros(self.input_shapes['calib'], dtype=np.float32), @@ -42,9 +43,10 @@ class ModelState: self.tensor_inputs = {k: Tensor(v, device=self.DEV).realize() for k,v in self.numpy_inputs.items()} self.calib_host = Tensor(self.numpy_inputs['calib'], device='NPY')._buffer() self._blob_cache : dict[int, Tensor] = {} - self.model_run = jits['run_model'] + self.model_run = jits['run'] + self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in jits['output_specs'].items()} with open(MODELS_DIR / f'dm_warp_{cam_w}x{cam_h}_tinygrad.pkl', "rb") as f: - self.image_warp = pickle.load(f) + self.image_warp = pickle.load(f)['run'] def run(self, buf: VisionBuf, calib: np.ndarray, transform: np.ndarray) -> tuple[np.ndarray, float]: self.numpy_inputs['calib'][0,:] = calib @@ -58,10 +60,10 @@ class ModelState: self._blob_cache[ptr] = Tensor.from_blob(ptr, (self.frame_buf_params[3],), dtype='uint8', device=self.DEV) self.warp_inputs_np['transform'][:] = transform[:] - self.tensor_inputs['input_img'] = self.image_warp(self._blob_cache[ptr], self.warp_inputs['transform']) + self.tensor_inputs['input_img'] = self.image_warp(input_frame=self._blob_cache[ptr], M_inv=self.warp_inputs['transform']) - output, = self.model_run(**self.tensor_inputs) - output = output.numpy().astype(np.float32).reshape(-1) + self.model_run(output_buffers=self.outputs, **self.tensor_inputs) + output = self.outputs['outputs'].numpy().astype(np.float32).reshape(-1) t2 = time.perf_counter() return output, t2 - t1 diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index db0b4d83b1..42187a04f3 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 from collections.abc import Callable +import base64 import ctypes from functools import cached_property import os @@ -139,11 +140,11 @@ class ModelState: def __init__(self, cam_w: int, cam_h: int, chestnut: bool): jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut))) - self.model_device = jits['input_devices']['model'] - self.input_shapes = jits['input_shapes'] - self.state_pairs = jits['state_pairs'] + self.model_device = jits['input_specs']['new_img'][2] + self.input_shapes = {name: (shape, np.dtype(dtype)) for name, (shape, dtype, _) in jits['input_specs'].items()} + self.state_pairs = {name: f'next_{name}' for name in self.input_shapes if f'next_{name}' in jits['metadata']['output_shapes']} self.vision_input_names = ('img', 'big_img') - self.output_slices = jits['metadata']['output_slices'] + self.output_slices = pickle.loads(base64.b64decode(jits['metadata']['metadata']['output_slices'])) self.prev_desire = np.zeros(ModelConstants.DESIRE_LEN, dtype=np.float32) self.chestnut = chestnut @@ -152,13 +153,17 @@ class ModelState: self.frame_copy_size = stride * (y_height + uv_height) self.pack_inputs() with open(MODELS_DIR / f'{"big_" if chestnut else ""}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl', 'rb') as f: - self.run_warp = pickle.load(f) - self.run_model = jits['run_model'] + self.run_warp = pickle.load(f)['run'] + self.run_model = jits['run'] + self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in jits['output_specs'].items()} + for name, next_name in self.state_pairs.items(): + state = self.input_queues[name] + self.outputs[next_name] = input_view(state._buffer(), state.shape, state.dtype, 0) self.parser = Parser() def pack_inputs(self) -> None: # Pack host inputs into one upload to reduce USB transfer overhead for the eGPU. - self.input_queues = {name: Tensor(np.zeros(shape, dtype=dtype.fmt), device=self.model_device).realize() + self.input_queues = {name: Tensor(np.zeros(shape, dtype=dtype), device=self.model_device).realize() for name, (shape, dtype) in self.input_shapes.items() if name in self.state_pairs} shapes = {'tfm': (2, 3, 3)} | {name: shape for name, (shape, _) in self.input_shapes.items() if name not in self.state_pairs and name != 'new_img'} @@ -173,7 +178,7 @@ class ModelState: self.input_queues[name] = input_view(self.input_device, shape, dtypes.float32, offset) offset += round_up(self.npy[name].nbytes, 128) self.frames = self.packed_input[npy_size:].reshape(2, self.frame_copy_size) - self.warp_inputs = (input_view(self.input_device, self.frames.shape, dtypes.uint8, npy_size), self.input_queues.pop('tfm')) + self.warp_inputs = {'input_frame': input_view(self.input_device, self.frames.shape, dtypes.uint8, npy_size), 'M_inv': self.input_queues.pop('tfm')} def slice_outputs(self, model_outputs: np.ndarray, output_slices: dict[str, slice]) -> dict[str, np.ndarray]: return {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()} @@ -192,11 +197,11 @@ class ModelState: self.npy['action_t'][:] = inputs['action_t'] self.input_device.copy_from(self.input_host) - self.input_queues['new_img'] = self.run_warp(*self.warp_inputs) - outs, = self.run_model(**self.input_queues) + self.input_queues['new_img'] = self.run_warp(**self.warp_inputs) + self.run_model(output_buffers=self.outputs, **self.input_queues) if after_enqueue is not None: after_enqueue() - model_output = outs.numpy()[0] + model_output = self.outputs['outputs'].numpy()[0] if self.chestnut and not np.all(np.isfinite(model_output)): raise RuntimeError("model output not finite") outputs_dict = self.parser.parse_outputs(self.slice_outputs(model_output, self.output_slices)) diff --git a/tinygrad_repo b/tinygrad_repo index 953a7f36cf..d5e17c935d 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit 953a7f36cfda50db17ee94505143474061c12e9d +Subproject commit d5e17c935daf11f6318e45aade9528f71b8fbdcc From 6080cc6168023229437b0ff06cf35bead00a5d0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Wed, 16 Sep 2026 08:13:45 -0700 Subject: [PATCH 122/270] Use a precompiled eGPU driving model (#38930) * Ship precompiled eGPU model and camera warps Compile f78ed37d-afad-4dbc-8050-40ea885eedde/12864 through xx/ml_tools/openpilot_compile using the pinned tinygrad version. * Precompile the existing master driving model Use the unchanged master ONNX (SHA-256 6fee5937923c74848df4a63f6239eb6331c6274dd4bdb7a5d6ec0388a8b543d5) instead of updating the trained model. * Compile camera warps on device * Remove obsolete ONNX chunking and big model build check * Chunk model artifacts only during release packaging * Require model and camera warps for Chestnut readiness * Recompile precompiled CPU helpers for the runtime host * Ship the eGPU model with an ARM submission helper * Exempt model pickles from the build product size limit --- .gitattributes | 1 + .github/workflows/docs.yaml | 2 +- .github/workflows/release.yaml | 2 +- .github/workflows/repo-maintenance.yaml | 2 +- .github/workflows/tests.yaml | 2 +- .gitignore | 1 + SConstruct | 2 + openpilot/common/file_chunker.py | 14 +---- openpilot/selfdrive/modeld/SConscript | 55 +++++-------------- openpilot/selfdrive/modeld/helpers.py | 4 +- .../modeld/models/big_driving_supercombo.onnx | 3 - .../modeld/models/big_driving_tinygrad.pkl | 3 + openpilot/selfdrive/test/chestnut.sh | 3 - openpilot/selfdrive/test/setup_device_ci.sh | 5 +- tools/release/build_release.sh | 8 ++- tools/release/build_stripped.sh | 2 +- tools/release/release_files.py | 2 +- 17 files changed, 39 insertions(+), 72 deletions(-) delete mode 100644 openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx create mode 100644 openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl diff --git a/.gitattributes b/.gitattributes index 2f8f3ece32..ef658d3055 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ # to move existing files into LFS: # git add --renormalize . *.onnx filter=lfs diff=lfs merge=lfs -text +openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text *.svg filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text *.gif filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 7997b86b7e..88c071475d 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -18,7 +18,7 @@ concurrency: env: GIT_CONFIG_COUNT: 1 GIT_CONFIG_KEY_0: lfs.fetchexclude - GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl jobs: docs: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a5a91a482b..76948d1057 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -7,7 +7,7 @@ on: env: GIT_CONFIG_COUNT: 1 GIT_CONFIG_KEY_0: lfs.fetchexclude - GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl jobs: build_masterci: diff --git a/.github/workflows/repo-maintenance.yaml b/.github/workflows/repo-maintenance.yaml index ef4a2795c2..7feb77ff46 100644 --- a/.github/workflows/repo-maintenance.yaml +++ b/.github/workflows/repo-maintenance.yaml @@ -11,7 +11,7 @@ env: PYTHONPATH: ${{ github.workspace }} GIT_CONFIG_COUNT: 1 GIT_CONFIG_KEY_0: lfs.fetchexclude - GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl jobs: package_updates: diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b8b1ace97a..ff23cf5c01 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -22,7 +22,7 @@ env: PYTHONPATH: ${{ github.workspace }} GIT_CONFIG_COUNT: 1 GIT_CONFIG_KEY_0: lfs.fetchexclude - GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl jobs: build_release: diff --git a/.gitignore b/.gitignore index 54f9f176b7..ff67a1cab8 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ st[0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z] *.stats *.pkl *.pkl* +!openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl config.json compile_commands.json compare_runtime*.html diff --git a/SConstruct b/SConstruct index c6d758b318..d524a20e0f 100644 --- a/SConstruct +++ b/SConstruct @@ -342,6 +342,8 @@ AddPostAction(BUILD_TARGETS or [Dir('.')], prune_cache_dir) def check_build_product_size(target, source, env): limit = 50 * 1024 * 1024 # GitHub max size for t in target: + if str(t).endswith('.pkl'): # chunked during release packaging + continue if hasattr(t, 'isfile') and t.isfile() and (size := os.path.getsize(t.abspath)) > limit: raise SCons.Errors.UserError(f"{t} is {size / (1024 * 1024):.1f} MiB, exceeding the {limit / (1024 * 1024):.1f} MiB limit") if not GetOption('extras'): diff --git a/openpilot/common/file_chunker.py b/openpilot/common/file_chunker.py index 5bf30de9ab..28a7e1212e 100755 --- a/openpilot/common/file_chunker.py +++ b/openpilot/common/file_chunker.py @@ -32,14 +32,6 @@ def chunk_file(path, targets): Path(manifest_path).write_text(str(len(chunk_paths))) os.remove(path) -def get_existing_chunks(path): - if os.path.isfile(path): - return [path] - if os.path.isfile(manifest := get_manifest_path(path)): - num_chunks = int(Path(manifest).read_text().strip()) - return _chunk_paths(path, num_chunks) - raise FileNotFoundError(path) - class ChunkStream(io.RawIOBase): def __init__(self, paths): self._paths = iter(paths) @@ -67,11 +59,11 @@ class ChunkStream(io.RawIOBase): def open_file_chunked(path): manifest_path = get_manifest_path(path) - if os.path.isfile(manifest_path): + if os.path.isfile(path): + paths = [path] + elif os.path.isfile(manifest_path): num_chunks = int(Path(manifest_path).read_text().strip()) paths = [get_chunk_name(path, i, num_chunks) for i in range(num_chunks)] - elif os.path.isfile(path): - paths = [path] else: raise FileNotFoundError(path) return io.BufferedReader(ChunkStream(paths)) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index c42e9f2335..2aac90fe63 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -1,18 +1,14 @@ import glob import os -import shutil -import tempfile import time from SCons.Script import Action, Value -from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_existing_chunks, open_file_chunked from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE -from openpilot.selfdrive.modeld.helpers import chestnut_present, modeld_pkl_path +from openpilot.selfdrive.modeld.helpers import chestnut_present from openpilot.system.camerad.cameras.nv12_info import get_nv12_info Import('env', 'arch') -chunker_file = File("#openpilot/common/file_chunker.py") lenv = env.Clone() lenv.PrependENVPath('PYTHONPATH', Dir('#tinygrad_repo').abspath) @@ -20,11 +16,6 @@ tinygrad_root = env.Dir("#").abspath tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=tinygrad_root) if 'pycache' not in x and os.path.isfile(os.path.join(tinygrad_root, x))] -def estimate_pickle_max_size(onnx_size): - # QCOM programs for models with spatial recurrent features can approach 2x - # the ONNX size. Overestimating only adds an empty trailing chunk. - return 2.0 * onnx_size + 10 * 1024 * 1024 - camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)] if arch == 'comma_arm64': @@ -47,7 +38,7 @@ compiler = Dir('#tinygrad_repo/examples/openpilot').abspath # CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' -def chestnut_action(command, pkl=None, chunks=()): +def chestnut_action(command): def do_compile(target, source, env): from openpilot.system.hardware.chestnut.flash import link_up # chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars @@ -56,47 +47,27 @@ def chestnut_action(command, pkl=None, chunks=()): break time.sleep(1) else: - print("Chestnut not ready, skipping big model build") + print("Chestnut not ready, skipping warp build") return - if ret := env.Execute(command): - return ret - if chunks: - chunk_file(pkl, chunks) + return env.Execute(command) return Action(do_compile, " [CHESTNUT] $TARGET") -def compile_model(onnx_path, pkl_path, flags, chestnut=False): +def compile_model(onnx_path, pkl_path): onnx_path, target_pkl_path = File(onnx_path).abspath, File(pkl_path).abspath - onnx_deps = get_existing_chunks(onnx_path) - cmd = (f'{flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_onnx.py" ' - f'"{{onnx}}" "{target_pkl_path}" --device-input "*" --out-of-band --benchmark-runs 1') - def do_compile(target, source, env): - if os.path.isfile(onnx_path): - return env.Execute(cmd.format(onnx=onnx_path)) - # TODO: Remove ONNX chunk reassembly once models are precompiled. - with tempfile.NamedTemporaryFile(dir=os.path.dirname(onnx_path), suffix='.onnx') as tmp, open_file_chunked(onnx_path) as src: - shutil.copyfileobj(src, tmp) - tmp.flush() - return env.Execute(cmd.format(onnx=tmp.name)) - compile_action = Action(do_compile, " [ONNX] $TARGET") - onnx_sizes_sum = sum(os.path.getsize(f) for f in onnx_deps) - chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) - def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets): - chunk_file(pkl, chunks) - actions = chestnut_action(compile_action, target_pkl_path, chunk_targets) if chestnut else [compile_action, Action(do_chunk, " [CHUNK] $TARGET")] - node = lenv.Command( - chunk_targets, - tinygrad_files + onnx_deps + [Value(cmd), Value(chunk_targets), chunker_file], - actions, + cmd = (f'{tg_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_onnx.py" ' + f'"{onnx_path}" "{target_pkl_path}" --device-input "*" --out-of-band --benchmark-runs 1') + lenv.Command( + target_pkl_path, + tinygrad_files + [onnx_path, Value(cmd)], + Action(cmd, " [ONNX] $TARGET"), ) - if chestnut: - lenv.SideEffect(chestnut_lock, node) -compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygrad.pkl', tg_flags) +compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygrad.pkl') +compile_model('models/driving_supercombo.onnx', 'models/driving_tinygrad.pkl') model_w, model_h = MEDMODEL_INPUT_SIZE for chestnut in [False, True] if CHESTNUT else [False]: file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) - compile_model(f'models/{file_prefix}driving_supercombo.onnx', modeld_pkl_path(chestnut), cmd_flags, chestnut) for cam_w, cam_h in camera_configs: warp_pkl_path = File(f"models/{file_prefix}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 307cb7c163..5e7f413c26 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -35,4 +35,6 @@ def chestnut_present() -> bool: return False def chestnut_compiled() -> bool: - return Path(get_manifest_path(modeld_pkl_path(chestnut=True))).is_file() + path = modeld_pkl_path(chestnut=True) + return (path.is_file() or Path(get_manifest_path(path)).is_file()) and all( + (MODELS_DIR / f'big_driving_warp_{size}_tinygrad.pkl').is_file() for size in ('1344x760', '1928x1208')) diff --git a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx deleted file mode 100644 index 3646adc744..0000000000 --- a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6fee5937923c74848df4a63f6239eb6331c6274dd4bdb7a5d6ec0388a8b543d5 -size 766018462 diff --git a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl new file mode 100644 index 0000000000..9a99ee2633 --- /dev/null +++ b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76cc0a9bc3af7a318889483dcbe126337f8d338f5abcbe664a8988c9b18b6639 +size 776634338 diff --git a/openpilot/selfdrive/test/chestnut.sh b/openpilot/selfdrive/test/chestnut.sh index fdb1967429..6fc645ee69 100755 --- a/openpilot/selfdrive/test/chestnut.sh +++ b/openpilot/selfdrive/test/chestnut.sh @@ -3,7 +3,4 @@ set -e sudo python3 openpilot/system/hardware/chestnut/flash.py -TARGET=openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest -rm -f "$TARGET" SCONSFLAGS="-j4" ./openpilot/system/manager/build.py -test -s "$TARGET" diff --git a/openpilot/selfdrive/test/setup_device_ci.sh b/openpilot/selfdrive/test/setup_device_ci.sh index a1dd88dcf4..e3ee1fc3db 100755 --- a/openpilot/selfdrive/test/setup_device_ci.sh +++ b/openpilot/selfdrive/test/setup_device_ci.sh @@ -64,9 +64,8 @@ pull_lfs() { return fi - # The big driving model is not used on these devices yet. Keep its pointer in - # the worktree, but don't download or copy the 1.8 GB LFS object. - LFS_EXCLUDE="openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" + # Keep the precompiled big model as a pointer on devices without Chestnut. + LFS_EXCLUDE="openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl" git config --local lfs.fetchexclude "$LFS_EXCLUDE" git lfs pull --exclude="$LFS_EXCLUDE" diff --git a/tools/release/build_release.sh b/tools/release/build_release.sh index 6dcf99957f..42417d7ace 100755 --- a/tools/release/build_release.sh +++ b/tools/release/build_release.sh @@ -49,9 +49,6 @@ for policy in /sys/devices/system/cpu/cpufreq/policy*; do done scons -if [ -n "$INCLUDE_BIG_MODEL" ]; then - test -f openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest -fi if [ -z "$PANDA_DEBUG_BUILD" ]; then # release panda fw @@ -61,6 +58,11 @@ else scons panda/ fi +find openpilot/selfdrive/modeld/models -name '*.pkl' -size +95M -exec ./openpilot/common/file_chunker.py {} \; +if [ -n "$INCLUDE_BIG_MODEL" ]; then + test -f openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest +fi + # Ensure no submodules in release if test "$(git submodule--helper list | wc -l)" -gt "0"; then echo "submodules found:" diff --git a/tools/release/build_stripped.sh b/tools/release/build_stripped.sh index 239add2518..7b9fae4c98 100755 --- a/tools/release/build_stripped.sh +++ b/tools/release/build_stripped.sh @@ -39,7 +39,7 @@ cd "$SOURCE_DIR" cd "$TARGET_DIR" rm -rf .git/modules/ -find openpilot/selfdrive/modeld/models -name '*.onnx' -size +95M -exec ./openpilot/common/file_chunker.py {} \; +find openpilot/selfdrive/modeld/models -name '*.pkl' -size +95M -exec ./openpilot/common/file_chunker.py {} \; # include source commit hash and build date in commit GIT_HASH=$(git --git-dir="$SOURCE_DIR/.git" rev-parse HEAD) diff --git a/tools/release/release_files.py b/tools/release/release_files.py index dd42125337..dd963eaced 100755 --- a/tools/release/release_files.py +++ b/tools/release/release_files.py @@ -32,7 +32,7 @@ if __name__ == "__main__": continue rf = os.fsdecode(tracked_file) - if not os.getenv("INCLUDE_BIG_MODEL") and rf.startswith("openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx"): + if not os.getenv("INCLUDE_BIG_MODEL") and rf.startswith("openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl"): continue blacklisted = any(re.search(p, rf) for p in blacklist) whitelisted = any(re.search(p, rf) for p in whitelist) From cab53438aefe5b4d48721da4af09efacd0d19451 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Wed, 16 Sep 2026 19:10:00 -0700 Subject: [PATCH 123/270] AGNOS 19.8 (#38935) --- launch_env.sh | 2 +- openpilot/common/hardware/comma/agnos.json | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/launch_env.sh b/launch_env.sh index 4fcb243f12..0cb06e7e5b 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -16,7 +16,7 @@ export VECLIB_MAXIMUM_THREADS=1 export QCOM_PRIORITY=12 if [ -z "$AGNOS_VERSION" ]; then - export AGNOS_VERSION="19.7" + export AGNOS_VERSION="19.8" fi export STAGING_ROOT="/data/safe_staging" diff --git a/openpilot/common/hardware/comma/agnos.json b/openpilot/common/hardware/comma/agnos.json index 4a48016ed2..92c6a4c7cb 100644 --- a/openpilot/common/hardware/comma/agnos.json +++ b/openpilot/common/hardware/comma/agnos.json @@ -56,29 +56,29 @@ }, { "name": "boot", - "url": "https://commadist.azureedge.net/agnosupdate/boot-6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d.img.xz", - "hash": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d", - "hash_raw": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d", + "url": "https://commadist.azureedge.net/agnosupdate/boot-335c17579879614a3b6f9b58c867aa2e66609530258e12d9174d6007494fdb11.img.xz", + "hash": "335c17579879614a3b6f9b58c867aa2e66609530258e12d9174d6007494fdb11", + "hash_raw": "335c17579879614a3b6f9b58c867aa2e66609530258e12d9174d6007494fdb11", "size": 46897152, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "d12e1e5b9455b62a1464558716493b33e470d7a7e88da1c4105a3b21d0961808" + "ondevice_hash": "545d64ff38626d0aff49054cdb7abc735992178f4872fd26ac544d652af38544" }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img.xz", - "hash": "74ffc9c551e1f29cda897ace8a69080fe644f8039977c6885f2b48362e39b744", - "hash_raw": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f", + "url": "https://commadist.azureedge.net/agnosupdate/system-20d7a522190ba8b6351364beca9702f40303651577d7c3f6c2c1cb6b34e5db6b.img.xz", + "hash": "62c6ecab2772f48b90dd2e7f0c3bb413dd7577d1ae9aad971135ce0d9d141544", + "hash_raw": "20d7a522190ba8b6351364beca9702f40303651577d7c3f6c2c1cb6b34e5db6b", "size": 4718592000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "6a992680183685eea9db99d915219a37935f45989330d9b619e880450257f448", + "ondevice_hash": "14251b982859824ea12f5e5c63b336b6d6333779d42457aff7e43bbf9c9fd79b", "alt": { - "hash": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f", - "url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img", + "hash": "20d7a522190ba8b6351364beca9702f40303651577d7c3f6c2c1cb6b34e5db6b", + "url": "https://commadist.azureedge.net/agnosupdate/system-20d7a522190ba8b6351364beca9702f40303651577d7c3f6c2c1cb6b34e5db6b.img", "size": 4718592000 } } -] \ No newline at end of file +] From 4d9d1bc34168215ba8a093b168e0073acf5336cc Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:34:52 -0400 Subject: [PATCH 124/270] ui: not paired bookmark alert (#38936) * check connect alert * remove test * simplify * clean diff * fit with bookmark icon out * clean --- openpilot/cereal/log.capnp | 1 + openpilot/selfdrive/selfdrived/events.py | 4 ++++ openpilot/selfdrive/selfdrived/selfdrived.py | 4 +++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 086f6ca719..d8a761573c 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -128,6 +128,7 @@ struct OnroadEvent @0xc4fa6047f024e718 { personalityChanged @91; aeb @92; userBookmark @95; + userBookmarkNotPaired @104; excessiveActuation @96; bigModelLoading @100; bigModelFailed @102; diff --git a/openpilot/selfdrive/selfdrived/events.py b/openpilot/selfdrive/selfdrived/events.py index 69fa900be4..b430b91c82 100755 --- a/openpilot/selfdrive/selfdrived/events.py +++ b/openpilot/selfdrive/selfdrived/events.py @@ -1008,6 +1008,10 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { EventName.userBookmark: { ET.PERMANENT: NormalPermanentAlert("Bookmark Saved", duration=1.5), }, + + EventName.userBookmarkNotPaired: { + ET.PERMANENT: NormalPermanentAlert("Bookmark Saved", "pair to connect\nto view", duration=1.5), + }, } diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index f7636ce427..0a468091a7 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -199,7 +199,9 @@ class SelfdriveD: # Check for user bookmark press if self.sm.updated['userBookmark']: - self.events.add(EventName.userBookmark) + prime_type = self.params.get("PrimeType") + paired = prime_type is not None and int(prime_type) >= 0 + self.events.add(EventName.userBookmark if paired else EventName.userBookmarkNotPaired) # Don't add any more events while in dashcam mode if self.CP.passive: From bd176cb6a7c9aec51bb92577b7e3a6035a6986c6 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Thu, 17 Sep 2026 00:59:12 -0700 Subject: [PATCH 125/270] ui: remove question marks (#38938) fix question marks --- .../ui/mici/layouts/settings/network/network_layout.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py index 4203336469..d38d640bdd 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -30,7 +30,7 @@ class NetworkLayoutMici(NavScroller): self._wifi_manager.set_tethering_active(checked) self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback, - description="Share the device’s internet connection through a Wi-Fi hotspot.") + description="Share the device's internet connection through a Wi-Fi hotspot.") def tethering_password_callback(password: str): if password: @@ -61,7 +61,7 @@ class NetworkLayoutMici(NavScroller): # TODO: signal for current network metered type when changing networks, this is wrong until you press it once # TODO: disable when not connected self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback, - description="Metered prevents large uploads on this Wi-Fi connection. Default uses the network’s detected " + + description="Metered prevents large uploads on this Wi-Fi connection. Default uses the network's detected " + "setting.") self._network_metered_btn.set_enabled(False) From cd1490af4beca7e9edcd3b21daf6737aadff2a4a Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Thu, 17 Sep 2026 14:31:42 -0700 Subject: [PATCH 126/270] modeld: 2x faster chestnut build (#38656) * not worth 1m * saves 24s * save 13s * parallelize model compilation * explain compiler affinity * full cpufreq * rm that for now * cmt --------- Co-authored-by: elkoled --- openpilot/selfdrive/modeld/SConscript | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 2aac90fe63..b6274f5cc5 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -35,8 +35,8 @@ mac_brew_string = f'HOME={os.path.expanduser("~")}' if arch == 'Darwin' else '' warp_deps = [File("#openpilot/system/camerad/cameras/nv12_info.py")] compiler = Dir('#tinygrad_repo/examples/openpilot').abspath -# CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. -taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' +# build on all cores +taskset = 'taskset -c 0-7 ' if arch == 'comma_arm64' else '' def chestnut_action(command): def do_compile(target, source, env): From 7db7735598cd6a44a1c4e1d146c27b2611bbdb07 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 17 Sep 2026 15:58:41 -0700 Subject: [PATCH 127/270] rm chestnut power test (#38943) * test power draw with chestnut * revert chestnut power draw test * Revert "add powertest for mici (#38843)" This reverts commit f174e39d7a799d2523da0650e043973a3389451e. --- Jenkinsfile | 1 - .../common/hardware/comma/power_monitor.py | 13 +++++------- openpilot/selfdrive/test/test_power_draw.py | 21 ++++--------------- 3 files changed, 9 insertions(+), 26 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 67039d0927..6908fa6c3d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -265,7 +265,6 @@ node { step("build", "./openpilot/selfdrive/test/chestnut.sh"), step("model replay", "openpilot/selfdrive/test/process_replay/model_replay.py --chestnut"), step("onroad tests", "./openpilot/selfdrive/test/test_onroad.py TestChestnutOnroad", [timeout: 120]), - step("test power draw", "./openpilot/selfdrive/test/test_power_draw.py"), ]) }, diff --git a/openpilot/common/hardware/comma/power_monitor.py b/openpilot/common/hardware/comma/power_monitor.py index 9ae2c9e138..296290dae8 100755 --- a/openpilot/common/hardware/comma/power_monitor.py +++ b/openpilot/common/hardware/comma/power_monitor.py @@ -9,25 +9,22 @@ from openpilot.common.realtime import Ratekeeper from openpilot.common.filter_simple import FirstOrderFilter -def read_power(panda=None): - if panda is not None and panda.get_type() == panda.HW_TYPE_CUATRO: - health = panda.health() - return health['voltage'] * health['current'] / 1e6 +def read_power(): with open("/sys/bus/i2c/devices/0-0040/hwmon/hwmon1/power1_input") as f: return int(f.read()) / 1e6 -def sample_power(seconds=5, panda=None) -> list[float]: +def sample_power(seconds=5) -> list[float]: rate = 123 rk = Ratekeeper(rate, print_delay_threshold=None) pwrs = [] for _ in range(rate*seconds): - pwrs.append(read_power(panda)) + pwrs.append(read_power()) rk.keep_time() return pwrs -def get_power(seconds=5, panda=None): - pwrs = sample_power(seconds, panda) +def get_power(seconds=5): + pwrs = sample_power(seconds) return np.mean(pwrs) def wait_for_power(min_pwr, max_pwr, min_secs_in_range, timeout): diff --git a/openpilot/selfdrive/test/test_power_draw.py b/openpilot/selfdrive/test/test_power_draw.py index d8b234fe60..1d0be24ef0 100755 --- a/openpilot/selfdrive/test/test_power_draw.py +++ b/openpilot/selfdrive/test/test_power_draw.py @@ -5,8 +5,6 @@ import time import unittest import numpy as np from dataclasses import dataclass -from panda import Panda -from openpilot.common.hardware import HARDWARE from openpilot.common.test import OpenpilotTestCase from openpilot.common.utils import tabulate @@ -16,14 +14,11 @@ from opendbc.car.car_helpers import get_demo_car_params from openpilot.common.mock import mock_messages from openpilot.common.params import Params from openpilot.common.hardware.comma.power_monitor import get_power -from openpilot.selfdrive.modeld.helpers import chestnut_present from openpilot.system.manager.process_config import managed_processes from openpilot.system.manager.manager import manager_cleanup SAMPLE_TIME = 2 # seconds to sample power MAX_WARMUP_TIME = 30 # seconds to wait for SAMPLE_TIME consecutive valid samples -MICI = HARDWARE.get_device_type() == "mici" -CHESTNUT = chestnut_present() @dataclass class Proc: @@ -38,10 +33,9 @@ class Proc: return '+'.join(self.procs) -# MICI readings exclude the separately powered Chestnut GPU. PROCS = [ - Proc(['camerad'], 0.85 if MICI else 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']), - Proc(['modeld'], 0.45 if MICI and CHESTNUT else 1.5, atol=0.2, msgs=['modelV2']), + Proc(['camerad'], 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']), + Proc(['modeld'], 1.5, atol=0.2, msgs=['modelV2']), Proc(['dmonitoringmodeld'], 0.65, atol=0.35, msgs=['driverStateV2']), Proc(['encoderd'], 0.23, msgs=[]), ] @@ -52,13 +46,6 @@ class TestPowerDraw(OpenpilotTestCase): def setup_method(self): Params().put("CarParams", get_demo_car_params().to_bytes(), block=True) - self.panda = None - if MICI: - HARDWARE.reset_internal_panda() - self.addCleanup(HARDWARE.reset_internal_panda) - Panda.wait_for_panda(None, 30) - self.panda = Panda(cli=False) - self.addCleanup(self.panda.close) def teardown_method(self): manager_cleanup() @@ -91,7 +78,7 @@ class TestPowerDraw(OpenpilotTestCase): start_time = time.monotonic() while (time.monotonic() - start_time) < MAX_WARMUP_TIME: - power = get_power(1, self.panda) + power = get_power(1) iteration_msg_counts = {} for msg,sock in socks.items(): iteration_msg_counts[msg] = len(messaging.drain_sock_raw(sock)) @@ -110,7 +97,7 @@ class TestPowerDraw(OpenpilotTestCase): @mock_messages(['deviceMotion']) def test_camera_procs(self, subtests): - baseline = get_power(panda=self.panda) + baseline = get_power() prev = baseline used = {} From 1328ace0684c2850b0fb195ad73d31925b8e553f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Thu, 17 Sep 2026 16:34:52 -0700 Subject: [PATCH 128/270] Remove model chunking and use LFS for Chestnut releases (#38941) * Remove model chunking and use LFS for Chestnut releases * Check Git blob sizes once during release packaging * Download the big model for Chestnut nightly builds * Reuse published LFS models when pushing releases * Preserve LFS configuration when copying Chestnut releases * Detect release builds independently of LFS attributes --- Jenkinsfile | 2 +- SConstruct | 15 +--- openpilot/common/file_chunker.py | 75 ------------------- .../selfdrive/modeld/dmonitoringmodeld.py | 4 +- openpilot/selfdrive/modeld/helpers.py | 3 +- openpilot/selfdrive/modeld/modeld.py | 4 +- tools/release/README.md | 2 +- tools/release/build_release.sh | 8 +- tools/release/build_stripped.sh | 21 ++---- tools/release/check_file_sizes.sh | 8 ++ tools/release/release_files.py | 7 +- tools/release/setup_lfs.sh | 9 +++ 12 files changed, 39 insertions(+), 119 deletions(-) delete mode 100755 openpilot/common/file_chunker.py create mode 100644 tools/release/check_file_sizes.sh create mode 100644 tools/release/setup_lfs.sh diff --git a/Jenkinsfile b/Jenkinsfile index 6908fa6c3d..92a26d6265 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -204,7 +204,7 @@ node { } if (env.BRANCH_NAME == '__nightly-chestnut') { - deviceStage("build nightly-chestnut", "mici-chestnut-ci", [], [ + deviceStage("build nightly-chestnut", "mici-chestnut-ci", ["CHESTNUT=1"], [ step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 PANDA_DEBUG_BUILD=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), ]) } diff --git a/SConstruct b/SConstruct index d524a20e0f..00428ed435 100644 --- a/SConstruct +++ b/SConstruct @@ -20,7 +20,7 @@ SetOption('num_jobs', max(1, int(os.cpu_count()/(1 if "CI" in os.environ else 2) AddOption('--ccflags', action='store', type='string', default='', help='pass arbitrary flags over the command line') AddOption('--verbose', action='store_true', default=False, help='show full build commands') -release = not os.path.exists(File('#.gitattributes').abspath) # file absent on release branch, see release_files.py +release = not os.path.exists(File('#.gitmodules').abspath) # file absent on release branch, see release_files.py AddOption('--minimal', action='store_false', dest='extras', @@ -312,8 +312,6 @@ def count_scons_nodes(nodes): if node in seen: continue seen.add(node) - if hasattr(node, 'has_builder') and node.has_builder(): - build_product_nodes.add(node) executor = node.get_executor() if executor is not None: stack += executor.get_all_prerequisites() + executor.get_all_children() @@ -322,7 +320,6 @@ def count_scons_nodes(nodes): progress_interval = 5 progress_count = 0 -build_product_nodes = set() progress_total = max(1, count_scons_nodes(env.arg2nodes(BUILD_TARGETS or [Dir('.')], env.fs.Entry))) def progress_function(node): @@ -338,13 +335,3 @@ def progress_function(node): Progress(progress_function, interval=progress_interval) AddPostAction(BUILD_TARGETS or [Dir('.')], prune_cache_dir) - -def check_build_product_size(target, source, env): - limit = 50 * 1024 * 1024 # GitHub max size - for t in target: - if str(t).endswith('.pkl'): # chunked during release packaging - continue - if hasattr(t, 'isfile') and t.isfile() and (size := os.path.getsize(t.abspath)) > limit: - raise SCons.Errors.UserError(f"{t} is {size / (1024 * 1024):.1f} MiB, exceeding the {limit / (1024 * 1024):.1f} MiB limit") -if not GetOption('extras'): - AddPostAction(list(build_product_nodes), Action(check_build_product_size, None)) diff --git a/openpilot/common/file_chunker.py b/openpilot/common/file_chunker.py deleted file mode 100755 index 28a7e1212e..0000000000 --- a/openpilot/common/file_chunker.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 -import io -import sys -import math -import os -from pathlib import Path - -CHUNK_SIZE = 45 * 1024 * 1024 # 45MB, under GitHub's 50MB limit - -def get_chunk_name(name, idx, num_chunks): - return f"{name}.chunk{idx+1:02d}of{num_chunks:02d}" - -def get_manifest_path(name): - return f"{name}.chunkmanifest" - -def _chunk_paths(path, num_chunks): - return [get_manifest_path(path)] + [get_chunk_name(path, i, num_chunks) for i in range(num_chunks)] - -def get_chunk_targets(path, file_size): - num_chunks = math.ceil(file_size / CHUNK_SIZE) - return _chunk_paths(path, num_chunks) - -def chunk_file(path, targets): - manifest_path, *chunk_paths = targets - actual_num_chunks = max(1, math.ceil(os.path.getsize(path) / CHUNK_SIZE)) - assert len(chunk_paths) >= actual_num_chunks, f"Allowed {len(chunk_paths)} chunks but needs at least {actual_num_chunks}, for path {path}" - Path(manifest_path).unlink(missing_ok=True) - with open(path, 'rb') as f: - for chunk_path in chunk_paths: - with open(chunk_path, 'wb') as out: - out.write(f.read(CHUNK_SIZE)) - Path(manifest_path).write_text(str(len(chunk_paths))) - os.remove(path) - -class ChunkStream(io.RawIOBase): - def __init__(self, paths): - self._paths = iter(paths) - self._f = None - - def readable(self): - return True - - def readinto(self, b): - n = 0 - view = memoryview(b) - while n < len(b): - if self._f is None: - p = next(self._paths, None) - if p is None: - break - self._f = open(p, 'rb') - count = self._f.readinto(view[n:]) - if not count: - self._f.close() - self._f = None - continue - n += count - return n - -def open_file_chunked(path): - manifest_path = get_manifest_path(path) - if os.path.isfile(path): - paths = [path] - elif os.path.isfile(manifest_path): - num_chunks = int(Path(manifest_path).read_text().strip()) - paths = [get_chunk_name(path, i, num_chunks) for i in range(num_chunks)] - else: - raise FileNotFoundError(path) - return io.BufferedReader(ChunkStream(paths)) - - -if __name__ == "__main__": - path = sys.argv[1] - chunk_paths = get_chunk_targets(path, os.path.getsize(path)) - chunk_file(path, chunk_paths) diff --git a/openpilot/selfdrive/modeld/dmonitoringmodeld.py b/openpilot/selfdrive/modeld/dmonitoringmodeld.py index 06d0d2e5e7..1ca9aab49b 100755 --- a/openpilot/selfdrive/modeld/dmonitoringmodeld.py +++ b/openpilot/selfdrive/modeld/dmonitoringmodeld.py @@ -16,7 +16,6 @@ from openpilot.common.realtime import config_realtime_process from openpilot.common.transformations.model import dmonitoringmodel_intrinsics from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.system.camerad.cameras.nv12_info import get_nv12_info -from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.parse_model_outputs import sigmoid, safe_exp SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') @@ -28,7 +27,8 @@ class ModelState: output: np.ndarray def __init__(self, cam_w: int, cam_h: int): - jits = load_oob(open_file_chunked(MODEL_PKL_PATH)) + with open(MODEL_PKL_PATH, 'rb') as f: + jits = load_oob(f) self.DEV = jits['input_specs']['input_img'][2] self.input_shapes = jits['metadata']['input_shapes'] self.output_slices = pickle.loads(base64.b64decode(jits['metadata']['metadata']['output_slices'])) diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 5e7f413c26..8b376af380 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -3,7 +3,6 @@ import pickle import struct from pathlib import Path -from openpilot.common.file_chunker import get_manifest_path from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH, is_chestnut_usb_id MODELS_DIR = Path(__file__).resolve().parent / 'models' @@ -36,5 +35,5 @@ def chestnut_present() -> bool: def chestnut_compiled() -> bool: path = modeld_pkl_path(chestnut=True) - return (path.is_file() or Path(get_manifest_path(path)).is_file()) and all( + return path.is_file() and all( (MODELS_DIR / f'big_driving_warp_{size}_tinygrad.pkl').is_file() for size in ('1344x760', '1928x1208')) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 42187a04f3..e187ae0ce2 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -34,7 +34,6 @@ from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, should_stop, smooth_value, get_curvature_from_plan from openpilot.selfdrive.modeld.parse_model_outputs import Parser from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState -from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.constants import ModelConstants, Plan from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob @@ -139,7 +138,8 @@ class ModelState: prev_desire: np.ndarray # for tracking the rising edge of the pulse def __init__(self, cam_w: int, cam_h: int, chestnut: bool): - jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut))) + with open(modeld_pkl_path(chestnut), 'rb') as f: + jits = load_oob(f) self.model_device = jits['input_specs']['new_img'][2] self.input_shapes = {name: (shape, np.dtype(dtype)) for name, (shape, dtype, _) in jits['input_specs'].items()} self.state_pairs = {name: f'next_{name}' for name in self.input_shapes if f'next_{name}' in jits['metadata']['output_shapes']} diff --git a/tools/release/README.md b/tools/release/README.md index 7e2e7e2faf..25ae32f704 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -21,7 +21,7 @@ - [ ] update from new release -> previous release - [ ] fresh install with `openpilot-test.comma.ai` - [ ] drive on fresh install - - [ ] no submodules or LFS + - [ ] no submodules; only the Chestnut big driving model uses LFS - [ ] check MTBF, etc. - [ ] stress test passes in production - [ ] publish the blog post diff --git a/tools/release/build_release.sh b/tools/release/build_release.sh index 42417d7ace..5b3b2a3a8a 100755 --- a/tools/release/build_release.sh +++ b/tools/release/build_release.sh @@ -39,6 +39,7 @@ cd "$SOURCE_DIR" # in the directory cd "$BUILD_DIR" +source "$SOURCE_DIR/tools/release/setup_lfs.sh" # use the full CPU available for speeding up the build. # openpilot resets the CPU frequencies when test_onroad.py runs below. @@ -58,9 +59,8 @@ else scons panda/ fi -find openpilot/selfdrive/modeld/models -name '*.pkl' -size +95M -exec ./openpilot/common/file_chunker.py {} \; if [ -n "$INCLUDE_BIG_MODEL" ]; then - test -f openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest + test -f openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl fi # Ensure no submodules in release @@ -88,6 +88,7 @@ VERSION=$(cat openpilot/common/version.h | awk -F[\"-] '{print $2}') # writing larger objects is faster than compressing them on-device git -c core.compression=0 add -f . git -c core.compression=0 -c gc.auto=0 commit -m "openpilot v$VERSION" +source "$SOURCE_DIR/tools/release/check_file_sizes.sh" # Run tests cd "$BUILD_DIR" @@ -100,6 +101,7 @@ for branch in ${RELEASE_BRANCH//,/ }; do REFS+=("$BUILD_BRANCH:$branch") done # uploading the larger pack is faster than spending CPU to optimize it -git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin "${REFS[@]}" +# The big model is already published to LFS by the source branch. +GIT_LFS_SKIP_PUSH=1 git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin "${REFS[@]}" echo "[-] done T=$SECONDS" diff --git a/tools/release/build_stripped.sh b/tools/release/build_stripped.sh index 7b9fae4c98..d6aed67778 100755 --- a/tools/release/build_stripped.sh +++ b/tools/release/build_stripped.sh @@ -39,7 +39,7 @@ cd "$SOURCE_DIR" cd "$TARGET_DIR" rm -rf .git/modules/ -find openpilot/selfdrive/modeld/models -name '*.pkl' -size +95M -exec ./openpilot/common/file_chunker.py {} \; +source "$SOURCE_DIR/tools/release/setup_lfs.sh" # include source commit hash and build date in commit GIT_HASH=$(git --git-dir="$SOURCE_DIR/.git" rev-parse HEAD) @@ -60,29 +60,20 @@ date: $DATETIME master commit: $GIT_HASH " -# should be no submodules or LFS files +# should be no submodules or unexpected LFS files git submodule status -if [ ! -z "$(git lfs ls-files)" ]; then +if [ -z "$INCLUDE_BIG_MODEL" ] && [ -n "$(git lfs ls-files)" ]; then echo "LFS files detected!" exit 1 fi -# ensure files are within GitHub's limit -BIG_FILES="$(find . -type f -not -path './.git/*' -size +95M)" -if [ ! -z "$BIG_FILES" ]; then - printf '\n\n\n' - echo "Found files exceeding GitHub's 100MB limit:" - echo "$BIG_FILES" - exit 1 -fi +source "$SOURCE_DIR/tools/release/check_file_sizes.sh" if [ ! -z "$BRANCH" ]; then echo "[-] Pushing to $BRANCH T=$SECONDS" - # Reset hooks since releases exclude .venv - git config --local core.hooksPath .git/hooks - git lfs update --force # uploading the larger pack is faster than spending CPU to optimize it - git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin "tmp:$BRANCH" + # The big model is already published to LFS by the source branch. + GIT_LFS_SKIP_PUSH=1 git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin "tmp:$BRANCH" fi echo "[-] done T=$SECONDS, ready at $TARGET_DIR" diff --git a/tools/release/check_file_sizes.sh b/tools/release/check_file_sizes.sh new file mode 100644 index 0000000000..2563e78c2b --- /dev/null +++ b/tools/release/check_file_sizes.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +BIG_FILES="$(git ls-tree -rl HEAD | awk '$4 > 95 * 1024 * 1024 {print $5}')" +if [ -n "$BIG_FILES" ]; then + echo "Found Git blobs exceeding the 95 MiB release limit:" + echo "$BIG_FILES" + exit 1 +fi diff --git a/tools/release/release_files.py b/tools/release/release_files.py index dd963eaced..caa34d0f39 100755 --- a/tools/release/release_files.py +++ b/tools/release/release_files.py @@ -14,7 +14,7 @@ blacklist = [ "matlab.*.md", - # no LFS or submodules in release + # release scripts configure LFS for the big driving model ".lfsconfig", ".gitattributes", ".git$", @@ -22,8 +22,7 @@ blacklist = [ ] # gets you through the blacklist -whitelist: list[str] = [ -] +whitelist: list[str] = [r"^\.lfsconfig$"] if os.getenv("INCLUDE_BIG_MODEL") else [] if __name__ == "__main__": tracked_files = subprocess.check_output(["git", "ls-files", "-z", "--recurse-submodules"], cwd=ROOT).split(b"\0") @@ -32,7 +31,7 @@ if __name__ == "__main__": continue rf = os.fsdecode(tracked_file) - if not os.getenv("INCLUDE_BIG_MODEL") and rf.startswith("openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl"): + if not os.getenv("INCLUDE_BIG_MODEL") and rf == "openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl": continue blacklisted = any(re.search(p, rf) for p in blacklist) whitelisted = any(re.search(p, rf) for p in whitelist) diff --git a/tools/release/setup_lfs.sh b/tools/release/setup_lfs.sh new file mode 100644 index 0000000000..68464af9d6 --- /dev/null +++ b/tools/release/setup_lfs.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +if [ -n "$INCLUDE_BIG_MODEL" ]; then + echo 'openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text' > .gitattributes +fi + +# Reset filters and hooks since releases exclude .venv. +git config --local core.hooksPath "$(git rev-parse --git-common-dir)/hooks" +git lfs install --local --force From f992a60bbda401657554629ac9e6083146a12264 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Thu, 17 Sep 2026 17:43:25 -0700 Subject: [PATCH 129/270] op setup: 2x faster on Linux (warm venv) (#38945) --- tools/setup_dependencies.sh | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tools/setup_dependencies.sh b/tools/setup_dependencies.sh index c9e635836b..6f023f7b53 100755 --- a/tools/setup_dependencies.sh +++ b/tools/setup_dependencies.sh @@ -72,7 +72,8 @@ function install_linux_deps() { fi if [[ -d "/etc/udev/rules.d/" ]]; then - "${SUDO[@]}" tee /etc/udev/rules.d/11-openpilot.rules > /dev/null <<-EOF + local udev_rules + udev_rules=$(cat <<-EOF # Panda Jungle devices SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddcf", MODE="0666" SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddef", MODE="0666" @@ -89,11 +90,17 @@ function install_linux_deps() { # comma devices over ADB SUBSYSTEM=="usb", ATTR{idVendor}=="04d8", ATTR{idProduct}=="1234", ENV{adb_user}="yes" EOF + ) - # delete the old ones - "${SUDO[@]}" rm -f /etc/udev/rules.d/11-panda.rules /etc/udev/rules.d/12-panda_jungle.rules /etc/udev/rules.d/50-comma-adb.rules + if ! cmp -s /etc/udev/rules.d/11-openpilot.rules <<< "$udev_rules" || + [[ -e /etc/udev/rules.d/11-panda.rules || -e /etc/udev/rules.d/12-panda_jungle.rules || -e /etc/udev/rules.d/50-comma-adb.rules ]]; then + "${SUDO[@]}" tee /etc/udev/rules.d/11-openpilot.rules > /dev/null <<< "$udev_rules" - "${SUDO[@]}" udevadm control --reload-rules && "${SUDO[@]}" udevadm trigger || true + # delete the old ones + "${SUDO[@]}" rm -f /etc/udev/rules.d/11-panda.rules /etc/udev/rules.d/12-panda_jungle.rules /etc/udev/rules.d/50-comma-adb.rules + + "${SUDO[@]}" udevadm control --reload-rules && "${SUDO[@]}" udevadm trigger || true + fi fi } From d643860afa2be78559e9681c0f89094891aa7ec3 Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:10:57 -0400 Subject: [PATCH 130/270] ui: increase info subtext size (#38946) increase to 36 --- openpilot/selfdrive/ui/mici/layouts/settings/device.py | 4 ++-- openpilot/selfdrive/ui/mici/layouts/settings/software.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device.py b/openpilot/selfdrive/ui/mici/layouts/settings/device.py index c038f5fddc..7ce13959d3 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device.py @@ -101,11 +101,11 @@ class DeviceInfoLayoutMici(Widget): subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) max_width = int(self._rect.width - 20) self._dongle_id_label = UnifiedLabel("device ID", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) - self._dongle_id_text_label = UnifiedLabel(params.get("DongleId") or 'N/A', 32, max_width=max_width, text_color=subheader_color, + self._dongle_id_text_label = UnifiedLabel(params.get("DongleId") or 'N/A', 36, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, wrap_text=False) self._serial_number_label = UnifiedLabel("serial", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) - self._serial_number_text_label = UnifiedLabel(params.get("HardwareSerial") or 'N/A', 32, max_width=max_width, text_color=subheader_color, + self._serial_number_text_label = UnifiedLabel(params.get("HardwareSerial") or 'N/A', 36, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, wrap_text=False) def _render(self, _): diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 7bf17713a3..392c031f30 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -42,11 +42,11 @@ class SoftwareInfoLayoutMici(Widget): subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) max_width = int(self._rect.width - 20) self._version_label = UnifiedLabel("version", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) - self._version_text_label = UnifiedLabel("", 32, max_width=max_width, text_color=subheader_color, + self._version_text_label = UnifiedLabel("", 36, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, wrap_text=False) self._branch_label = UnifiedLabel("branch", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) - self._branch_text_label = UnifiedLabel("", 32, max_width=max_width, text_color=subheader_color, + self._branch_text_label = UnifiedLabel("", 36, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True) def _update_state(self): From 7dcb524058f8a48df42bfdfd41b8f4edfd0fbb10 Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:02:45 -0400 Subject: [PATCH 131/270] ui: abstract info stack (#38949) * abstract info stack * better seperation in device py * clean --- .../ui/mici/layouts/settings/device.py | 34 ++------------ .../ui/mici/layouts/settings/software.py | 44 +++++-------------- openpilot/selfdrive/ui/mici/widgets/info.py | 31 +++++++++++++ 3 files changed, 45 insertions(+), 64 deletions(-) create mode 100644 openpilot/selfdrive/ui/mici/widgets/info.py diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device.py b/openpilot/selfdrive/ui/mici/layouts/settings/device.py index 7ce13959d3..c85e484376 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device.py @@ -7,16 +7,15 @@ from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.time_helpers import system_time_valid from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller +from openpilot.selfdrive.ui.mici.widgets.info import InfoLayoutMici from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraDialog from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide, TermsPage -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, MousePos from openpilot.system.ui.lib.multilang import tr -from openpilot.system.ui.widgets import Widget from openpilot.selfdrive.ui.ui_state import device, ui_state -from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.html_render import HtmlRenderer from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID @@ -91,35 +90,10 @@ class EngagedConfirmationButton(BigButton): self.set_click_callback(lambda: _engaged_confirmation_click(callback, action_text, icon, exit_on_confirm=exit_on_confirm, red=red)) -class DeviceInfoLayoutMici(Widget): +class DeviceInfoLayoutMici(InfoLayoutMici): def __init__(self): - super().__init__() - - self.set_rect(rl.Rectangle(0, 0, 360, 180)) - params = Params() - subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) - max_width = int(self._rect.width - 20) - self._dongle_id_label = UnifiedLabel("device ID", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) - self._dongle_id_text_label = UnifiedLabel(params.get("DongleId") or 'N/A', 36, max_width=max_width, text_color=subheader_color, - font_weight=FontWeight.ROMAN, wrap_text=False) - - self._serial_number_label = UnifiedLabel("serial", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) - self._serial_number_text_label = UnifiedLabel(params.get("HardwareSerial") or 'N/A', 36, max_width=max_width, text_color=subheader_color, - font_weight=FontWeight.ROMAN, wrap_text=False) - - def _render(self, _): - self._dongle_id_label.set_position(self._rect.x + 20, self._rect.y - 10) - self._dongle_id_label.render() - - self._dongle_id_text_label.set_position(self._rect.x + 20, self._rect.y + 68 - 25) - self._dongle_id_text_label.render() - - self._serial_number_label.set_position(self._rect.x + 20, self._rect.y + 114 - 30) - self._serial_number_label.render() - - self._serial_number_text_label.set_position(self._rect.x + 20, self._rect.y + 161 - 25) - self._serial_number_text_label.render() + super().__init__("device ID", params.get("DongleId") or 'N/A', "serial", params.get("HardwareSerial") or 'N/A', width=380) class PairBigButton(BigButton): diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 392c031f30..0c86d8e884 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -6,13 +6,12 @@ from collections.abc import Callable from openpilot.common.time_helpers import system_time_valid from openpilot.selfdrive.ui.mici.layouts.settings.device import EngagedConfirmationButton +from openpilot.selfdrive.ui.mici.widgets.info import InfoLayoutMici from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, MousePos from openpilot.system.ui.lib.multilang import tr -from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.scroller import NavScroller UPDATER_TIMEOUT = 10.0 # seconds to wait for updater to respond @@ -33,44 +32,21 @@ class UpdaterState(IntEnum): UPDATER_RESPONDING = 2 -class SoftwareInfoLayoutMici(Widget): +class SoftwareInfoLayoutMici(InfoLayoutMici): def __init__(self): - super().__init__() - - self.set_rect(rl.Rectangle(0, 0, 360, 180)) - - subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) - max_width = int(self._rect.width - 20) - self._version_label = UnifiedLabel("version", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) - self._version_text_label = UnifiedLabel("", 36, max_width=max_width, text_color=subheader_color, - font_weight=FontWeight.ROMAN, wrap_text=False) - - self._branch_label = UnifiedLabel("branch", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) - self._branch_text_label = UnifiedLabel("", 36, max_width=max_width, text_color=subheader_color, - font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True) + super().__init__("version", "", "branch", "") def _update_state(self): + super()._update_state() + desc = _split_description(ui_state.params.get("UpdaterCurrentDescription") or "") if desc is not None: version, branch, commit, date = desc - self._version_text_label.set_text(f"{version} ({date})") - self._branch_text_label.set_text(f"{branch} ({commit})") + self.subtext1.set_text(f"{version} ({date})") + self.subtext2.set_text(f"{branch} ({commit})") else: - self._version_text_label.set_text(ui_state.params.get("Version") or "N/A") - self._branch_text_label.set_text(ui_state.params.get("GitBranch") or "N/A") - - def _render(self, _): - self._version_label.set_position(self._rect.x + 20, self._rect.y - 10) - self._version_label.render() - - self._version_text_label.set_position(self._rect.x + 20, self._rect.y + 68 - 25) - self._version_text_label.render() - - self._branch_label.set_position(self._rect.x + 20, self._rect.y + 114 - 30) - self._branch_label.render() - - self._branch_text_label.set_position(self._rect.x + 20, self._rect.y + 161 - 25) - self._branch_text_label.render() + self.subtext1.set_text(ui_state.params.get("Version") or "N/A") + self.subtext2.set_text(ui_state.params.get("GitBranch") or "N/A") class CheckUpdateButton(BigButton): diff --git a/openpilot/selfdrive/ui/mici/widgets/info.py b/openpilot/selfdrive/ui/mici/widgets/info.py new file mode 100644 index 0000000000..1b911d7a90 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/widgets/info.py @@ -0,0 +1,31 @@ +import pyray as rl + +from openpilot.system.ui.lib.application import FontWeight +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel + + +class InfoLayoutMici(Widget): + def __init__(self, title1: str, subtext1: str, title2: str, subtext2: str, *, width: int = 360): + super().__init__() + self.set_rect(rl.Rectangle(0, 0, width, 180)) + + subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) + max_width = int(self._rect.width - 20) + + self.title1 = UnifiedLabel(title1, 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) + self.subtext1 = UnifiedLabel(subtext1, 36, max_width=max_width, text_color=subheader_color, + font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True) + self.title2 = UnifiedLabel(title2, 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) + self.subtext2 = UnifiedLabel(subtext2, 36, max_width=max_width, text_color=subheader_color, + font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True) + + def _render(self, _): + self.title1.set_position(self._rect.x + 20, self._rect.y - 10) + self.title1.render() + self.subtext1.set_position(self._rect.x + 20, self._rect.y + 68 - 25) + self.subtext1.render() + self.title2.set_position(self._rect.x + 20, self._rect.y + 114 - 30) + self.title2.render() + self.subtext2.set_position(self._rect.x + 20, self._rect.y + 161 - 25) + self.subtext2.render() From 3cd1b6e48347a57eec6e0364af46fba4a2362381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Thu, 17 Sep 2026 20:43:51 -0700 Subject: [PATCH 132/270] Update tinygrad and use retargetable model artifacts (#38933) * Update tinygrad to latest upstream with minimal openpilot patches * Rebase tinygrad on latest upstream with one remaining patch * Use retargetable model and warp artifacts * Pack compiled model weights to reduce startup time * Memory-map out-of-band model weights * Use unmodified upstream tinygrad master * Keep locally built model artifacts native --- .gitmodules | 4 ++-- openpilot/selfdrive/modeld/SConscript | 2 +- openpilot/selfdrive/modeld/helpers.py | 18 +++++++++++------- openpilot/selfdrive/modeld/modeld.py | 2 ++ .../modeld/models/big_driving_tinygrad.pkl | 4 ++-- tinygrad_repo | 2 +- 6 files changed, 19 insertions(+), 13 deletions(-) diff --git a/.gitmodules b/.gitmodules index 15ab89d49c..a23c39fb03 100644 --- a/.gitmodules +++ b/.gitmodules @@ -15,5 +15,5 @@ url = ../../commaai/teleoprtc [submodule "tinygrad"] path = tinygrad_repo - url = https://github.com/commaai/tinygrad.git - branch = model-warp-compile + url = https://github.com/tinygrad/tinygrad.git + branch = master diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index b6274f5cc5..3d1bae9b44 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -55,7 +55,7 @@ def chestnut_action(command): def compile_model(onnx_path, pkl_path): onnx_path, target_pkl_path = File(onnx_path).abspath, File(pkl_path).abspath cmd = (f'{tg_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_onnx.py" ' - f'"{onnx_path}" "{target_pkl_path}" --device-input "*" --out-of-band --benchmark-runs 1') + f'"{onnx_path}" "{target_pkl_path}" --out-of-band --benchmark-runs 1') lenv.Command( target_pkl_path, tinygrad_files + [onnx_path, Value(cmd)], diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 8b376af380..bc5bc9e890 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -1,4 +1,4 @@ -import io +import mmap import pickle import struct from pathlib import Path @@ -13,14 +13,18 @@ def modeld_pkl_path(chestnut: bool): return MODELS_DIR / f'{prefix}driving_tinygrad.pkl' def load_oob(f): - opcodes = f.read(struct.unpack(' len(data): raise EOFError("incomplete model buffer") - yield pb - return pickle.load(io.BytesIO(opcodes), buffers=buffers()) + yield pickle.PickleBuffer(data[offset:offset + size]) + offset += size + return pickle.loads(data[8:8 + opcode_size], buffers=buffers()) def chestnut_present() -> bool: for d in USB_DEVICES_PATH.glob("*"): diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index e187ae0ce2..72e42b0307 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -7,6 +7,7 @@ import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom from tinygrad.device import Buffer, Device from tinygrad.dtype import DType, dtypes +from tinygrad.engine.realize import lower_and_compile from tinygrad.tensor import Tensor from tinygrad.helpers import round_up from tinygrad.uop.ops import UOp @@ -155,6 +156,7 @@ class ModelState: with open(MODELS_DIR / f'{"big_" if chestnut else ""}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl', 'rb') as f: self.run_warp = pickle.load(f)['run'] self.run_model = jits['run'] + self.run_model.captured._linear = lower_and_compile(self.run_model.captured._linear) self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in jits['output_specs'].items()} for name, next_name in self.state_pairs.items(): state = self.input_queues[name] diff --git a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl index 9a99ee2633..3f479d5e42 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76cc0a9bc3af7a318889483dcbe126337f8d338f5abcbe664a8988c9b18b6639 -size 776634338 +oid sha256:e00a53cb9c64fe70a58224bc7879e5ea1b7e18baa7710f0929bf4f2e4537a468 +size 776612574 diff --git a/tinygrad_repo b/tinygrad_repo index d5e17c935d..9fc9e60cb4 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit d5e17c935daf11f6318e45aade9528f71b8fbdcc +Subproject commit 9fc9e60cb4126b36700269c44081fec0fad62655 From 7635dec6182705b9516b400521eac447f3eb3fed Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:53:39 -0400 Subject: [PATCH 133/270] ui: qr widget (#38951) * rewrite pairing dialog into nice qr widget * simplify, move refresh to device pairing dialog * fix * refactor --- .../settings/device/green_settings.png | 3 + .../selfdrive/ui/mici/layouts/onboarding.py | 25 +---- .../ui/mici/layouts/settings/device.py | 54 ++++++++++- .../ui/mici/tests/test_widget_leaks.py | 2 +- .../ui/mici/widgets/pairing_dialog.py | 95 ------------------- openpilot/selfdrive/ui/mici/widgets/qr.py | 37 ++++++++ 6 files changed, 95 insertions(+), 121 deletions(-) create mode 100644 openpilot/selfdrive/assets/icons_mici/settings/device/green_settings.png delete mode 100644 openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py create mode 100644 openpilot/selfdrive/ui/mici/widgets/qr.py diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/green_settings.png b/openpilot/selfdrive/assets/icons_mici/settings/device/green_settings.png new file mode 100644 index 0000000000..230beb85dd --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/green_settings.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25db08a9038b5faa3b200ef5625cb216d7f4b31ea3d49fb0395f9b726bfaf8e6 +size 13135 diff --git a/openpilot/selfdrive/ui/mici/layouts/onboarding.py b/openpilot/selfdrive/ui/mici/layouts/onboarding.py index 0590440c6b..7cfba8f541 100644 --- a/openpilot/selfdrive/ui/mici/layouts/onboarding.py +++ b/openpilot/selfdrive/ui/mici/layouts/onboarding.py @@ -3,7 +3,7 @@ import numpy as np import pyray as rl from collections.abc import Callable from openpilot.common.filter_simple import FirstOrderFilter -from openpilot.common.qrcode import make_texture +from openpilot.selfdrive.ui.mici.widgets.qr import QR from openpilot.system.ui.lib.application import FontWeight, gui_app, TextAlignment from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import SmallCircleIconButton @@ -275,27 +275,6 @@ class TrainingGuide(NavWidget): self._steps[0].render(self._rect) -class QRCodeWidget(Widget): - def __init__(self, url: str, size: int = 170): - super().__init__() - self.set_rect(rl.Rectangle(0, 0, size, size)) - self._size = size - self._qr_texture: rl.Texture | None = None - self._generate_qr(url) - - def _generate_qr(self, url: str): - self._qr_texture = make_texture(url, inverted=True) - - def _render(self, _): - if self._qr_texture: - scale = self._size / self._qr_texture.height - rl.draw_texture_ex(self._qr_texture, rl.Vector2(round(self._rect.x), round(self._rect.y)), 0.0, scale, rl.WHITE) - - def __del__(self): - if self._qr_texture and self._qr_texture.id != 0: - rl.unload_texture(self._qr_texture) - - class TermsPage(Scroller): def __init__(self, on_accept, on_decline): super().__init__() @@ -312,7 +291,7 @@ class TermsPage(Scroller): self._terms_header, GreyBigButton("swipe for QR code", "or go to https://comma.ai/terms", gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 56, flip_x=True)), - QRCodeWidget("https://comma.ai/terms"), + QR("https://comma.ai/terms"), self._must_accept_card, self._accept_button, self._decline_button, diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device.py b/openpilot/selfdrive/ui/mici/layouts/settings/device.py index c85e484376..0a665f7a2d 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device.py @@ -1,16 +1,19 @@ import os import pyray as rl +import time from collections.abc import Callable from typing import Union +from openpilot.common.api import Api +from openpilot.common.swaglog import cloudlog from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.time_helpers import system_time_valid from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller from openpilot.selfdrive.ui.mici.widgets.info import InfoLayoutMici -from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton, GreyBigButton from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog -from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog +from openpilot.selfdrive.ui.mici.widgets.qr import QR from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraDialog from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide, TermsPage from openpilot.system.ui.lib.application import gui_app, MousePos @@ -96,6 +99,53 @@ class DeviceInfoLayoutMici(InfoLayoutMici): super().__init__("device ID", params.get("DongleId") or 'N/A', "serial", params.get("HardwareSerial") or 'N/A', width=380) +class PairingDialog(NavScroller): + """Pair the device with comma connect.""" + + QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds + + def __init__(self): + super().__init__() + self._params = Params() + self._last_pairing_qr_generation = float("-inf") + self._qr = QR(self._get_pairing_url()) + self._scroller._show_scroll_indicator = False + self._scroller.add_widgets([ + self._qr, + GreyBigButton("finish setup", "scan to pair device\nwith connect", + gui_app.texture("icons_mici/settings/device/green_settings.png", 64, 64)), + GreyBigButton("", "connect lets you review recent driving footage and bookmark events."), + ]) + + def _get_pairing_url(self) -> str: + try: + dongle_id = self._params.get("DongleId") or "" + token = Api(dongle_id).get_token({'pair': True}) + except Exception as e: + cloudlog.warning(f"Failed to get pairing token: {e}") + token = "" + return f"https://connect.comma.ai/?pair={token}" + + def _update_layout_rects(self): + super()._update_layout_rects() + self._qr.set_rect(rl.Rectangle(self._qr.rect.x, self._qr.rect.y, self._rect.height, self._rect.height)) + + def _render(self, rect: rl.Rectangle): + current_time = time.monotonic() + if current_time - self._last_pairing_qr_generation >= self.QR_REFRESH_INTERVAL: + self._qr._url = self._get_pairing_url() + if self._qr._texture and self._qr._texture.id != 0: + rl.unload_texture(self._qr._texture) + self._qr._texture = self._qr._generate_qr_code() + self._last_pairing_qr_generation = current_time + super()._render(rect) + + def _update_state(self): + super()._update_state() + if ui_state.prime_state.is_paired() and not self.is_dismissing: + self.dismiss() + + class PairBigButton(BigButton): def __init__(self): super().__init__("pair", "connect.comma.ai", gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60)) diff --git a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py index c0f1ac1510..a329a00e6b 100755 --- a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py +++ b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py @@ -52,7 +52,7 @@ class TestWidgetLeaks(OpenpilotTestCase): # mici dialogs from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide as MiciTrainingGuide, OnboardingWindow as MiciOnboardingWindow from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraDialog as MiciCabinCameraDialog - from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog as MiciPairingDialog + from openpilot.selfdrive.ui.mici.layouts.settings.device import PairingDialog as MiciPairingDialog from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigInputDialog from openpilot.selfdrive.ui.mici.layouts.settings.device import MiciFccModal diff --git a/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py b/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py deleted file mode 100644 index 7e05bc2fa0..0000000000 --- a/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py +++ /dev/null @@ -1,95 +0,0 @@ -import pyray as rl -import time - -from openpilot.common.api import Api -from openpilot.common.qrcode import make_texture -from openpilot.common.swaglog import cloudlog -from openpilot.common.params import Params -from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.widgets.nav_widget import NavWidget -from openpilot.system.ui.lib.application import FontWeight, gui_app -from openpilot.system.ui.widgets.label import UnifiedLabel - - -class PairingDialog(NavWidget): - """Dialog for device pairing with QR code.""" - - QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds - - def __init__(self): - super().__init__() - self._params = Params() - self._qr_texture: rl.Texture | None = None - self._last_qr_generation = float("-inf") - - self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 33, 60) - self._pair_label = UnifiedLabel("pair with comma connect", font_size=48, font_weight=FontWeight.BOLD, line_height=0.8) - - def _get_pairing_url(self) -> str: - try: - dongle_id = self._params.get("DongleId") or "" - token = Api(dongle_id).get_token({'pair': True}) - except Exception as e: - cloudlog.warning(f"Failed to get pairing token: {e}") - token = "" - return f"https://connect.comma.ai/?pair={token}" - - def _generate_qr_code(self) -> None: - try: - if self._qr_texture and self._qr_texture.id != 0: - rl.unload_texture(self._qr_texture) - self._qr_texture = make_texture(self._get_pairing_url(), inverted=True) - except Exception as e: - cloudlog.warning(f"QR code generation failed: {e}") - self._qr_texture = None - - def _check_qr_refresh(self) -> None: - current_time = time.monotonic() - if current_time - self._last_qr_generation >= self.QR_REFRESH_INTERVAL: - self._generate_qr_code() - self._last_qr_generation = current_time - - def _update_state(self): - super()._update_state() - if ui_state.prime_state.is_paired() and not self.is_dismissing: - self.dismiss() - - def _render(self, rect: rl.Rectangle): - self._check_qr_refresh() - - self._render_qr_code() - - label_x = self._rect.x + 8 + self._rect.height + 24 - self._pair_label.set_max_width(int(self._rect.width - label_x)) - self._pair_label.set_position(label_x, self._rect.y + 16) - self._pair_label.render() - - rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16), - 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35))) - - def _render_qr_code(self) -> None: - if not self._qr_texture: - error_font = gui_app.font(FontWeight.BOLD) - rl.draw_text_ex( - error_font, "QR Code Error", rl.Vector2(self._rect.x + 20, self._rect.y + self._rect.height // 2 - 15), 30, 0.0, rl.RED - ) - return - - scale = self._rect.height / self._qr_texture.height - pos = rl.Vector2(round(self._rect.x + 8), round(self._rect.y)) - rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE) - - def __del__(self): - if self._qr_texture and self._qr_texture.id != 0: - rl.unload_texture(self._qr_texture) - - -if __name__ == "__main__": - gui_app.init_window("pairing device") - pairing = PairingDialog() - gui_app.push_widget(pairing) - try: - for _ in gui_app.render(): - pass - finally: - del pairing diff --git a/openpilot/selfdrive/ui/mici/widgets/qr.py b/openpilot/selfdrive/ui/mici/widgets/qr.py new file mode 100644 index 0000000000..f0ff0d0ed9 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/widgets/qr.py @@ -0,0 +1,37 @@ +import pyray as rl + +from openpilot.common.swaglog import cloudlog +from openpilot.system.ui.lib.application import FontWeight +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import Label +from openpilot.common.qrcode import make_texture + + +class QR(Widget): + def __init__(self, url: str, width: int = 170): + super().__init__() + self._url = url + + self._texture = self._generate_qr_code() + + self.set_rect(rl.Rectangle(0, 0, width, width)) + self._error = Label("QR Code Error", font_size=30, font_weight=FontWeight.BOLD, text_color=rl.RED) + + def _generate_qr_code(self): + try: + return make_texture(self._url, inverted=True) + except Exception as e: + cloudlog.warning(f"QR code generation failed: {e}") + return None + + def _render(self, rect: rl.Rectangle): + if not self._texture: + self._error.render(rect) + return + scale = rect.height / self._texture.height + pos = rl.Vector2(round(rect.x), round(rect.y)) + rl.draw_texture_ex(self._texture, pos, 0.0, scale, rl.WHITE) + + def __del__(self): + if self._texture and self._texture.id != 0: + rl.unload_texture(self._texture) From bca3b047203dd7923c359ccebba4d025638be39f Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:56:31 -0700 Subject: [PATCH 134/270] cabana: improve dark theme contrast (#38957) --- openpilot/tools/cabana/ui/theme.cc | 24 ++++++++++++------------ openpilot/tools/cabana/ui/theme.h | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc index 14d0949835..6a544ef05a 100644 --- a/openpilot/tools/cabana/ui/theme.cc +++ b/openpilot/tools/cabana/ui/theme.cc @@ -12,16 +12,16 @@ namespace fs = std::filesystem; namespace { constexpr Palette DARK_PALETTE = { - .text = rgb(0xbbbbbb), .text_disabled = rgb(0x777777), .text_selected = rgb(0xbbbbbb), - .window = rgb(0x353535), .surface = rgb(0x3c3f41), - .frame = rgb(0x3c3f41), .frame_hovered = rgb(0x484b4d), .frame_active = rgb(0x505355), - .button = rgb(0x484b4d), .button_hovered = rgb(0x535658), .button_active = rgb(0x3c3f41), - .header = rgb(0x2f65ca), .header_hovered = rgb(0x414e65), .header_active = rgb(0x2f65ca), - .accent = rgb(0x2f65ca), - .border = rgb(0x282828), .separator = rgb(0x353535), .scrollbar_grab = rgb(0x484b4d), - .slider_track = rgb(0x777777), - .tab = rgb(0x353535), .tab_hovered = rgb(0x484b4d), .table_header = rgb(0x484b4d), - .grid = rgb(0xbbbbbb, 50.0f / 255.0f), .badge = rgb(0x808080), + .text = rgb(0xdddddd), .text_disabled = rgb(0x7b7b7b), .text_selected = rgb(0xdddddd), + .window = rgb(0x353535), .surface = rgb(0x2b2c2d), + .frame = rgb(0x2b2c2d), .frame_hovered = rgb(0x363738), .frame_active = rgb(0x434445), + .button = rgb(0x3f4041), .button_hovered = rgb(0x525354), .button_active = rgb(0x303132), + .header = rgb(0x2d74d2), .header_hovered = rgb(0x323944), .header_active = rgb(0x2d74d2), + .accent = rgb(0x2d74d2), + .border = rgb(0x606060), .separator = rgb(0x353535), .scrollbar_grab = rgb(0x4c4e4f), + .slider_track = rgb(0x7b7b7b), + .tab = rgb(0x2c2c2c), .tab_hovered = rgb(0x444647), .tab_selected = rgb(0x393a3b), .table_header = rgb(0x3f4041), + .grid = rgb(0xdddddd, 50.0f / 255.0f), .badge = rgb(0x808080), .bit_background = rgb(0xffffff, 20.0f / 255.0f), .heatmap_signal_alpha = 70.0, .heatmap_bit_alpha = 28.0, .heatmap_gamma = 0.6, .sparkline_saturation = 1.0f, .sparkline_value = 1.0f, @@ -36,7 +36,7 @@ constexpr Palette LIGHT_PALETTE = { .accent = rgb(0x308cc6), .border = rgb(0xb6b6b6), .separator = rgb(0xd0d0d0), .scrollbar_grab = rgb(0xb6b6b6), .slider_track = rgb(0xd0d0d0), - .tab = rgb(0xe5e5e5), .tab_hovered = rgb(0xefefef), .table_header = rgb(0xefefef), + .tab = rgb(0xe5e5e5), .tab_hovered = rgb(0xefefef), .tab_selected = rgb(0xffffff), .table_header = rgb(0xefefef), .grid = rgb(0x000000, 50.0f / 255.0f), .badge = rgb(0xa0a0a4), .bit_background = rgb(0xffffff, 0.0f), .heatmap_signal_alpha = 25.0, .heatmap_bit_alpha = 10.0, .heatmap_gamma = 1.0, @@ -142,7 +142,7 @@ void applyTheme(int theme) { c[ImGuiCol_TextSelectedBg] = alpha(p.accent, 0.35f); c[ImGuiCol_Tab] = c[ImGuiCol_TabDimmed] = p.tab; c[ImGuiCol_TabHovered] = p.tab_hovered; - c[ImGuiCol_TabSelected] = c[ImGuiCol_TabDimmedSelected] = p.surface; + c[ImGuiCol_TabSelected] = c[ImGuiCol_TabDimmedSelected] = p.tab_selected; c[ImGuiCol_TabDimmedSelectedOverline] = none; c[ImGuiCol_TableHeaderBg] = p.table_header; c[ImGuiCol_TableRowBgAlt] = none; diff --git a/openpilot/tools/cabana/ui/theme.h b/openpilot/tools/cabana/ui/theme.h index 9c8653cfe9..e7346db6a6 100644 --- a/openpilot/tools/cabana/ui/theme.h +++ b/openpilot/tools/cabana/ui/theme.h @@ -15,7 +15,7 @@ struct Palette { ImVec4 accent; ImVec4 border, separator, scrollbar_grab; ImVec4 slider_track; - ImVec4 tab, tab_hovered, table_header; + ImVec4 tab, tab_hovered, tab_selected, table_header; ImVec4 grid; ImVec4 badge; // the fill behind the time labels drawn over a chart ImVec4 bit_background; // overlay beneath heatmap bits without a signal From 60b3ccf39ef13b4f2a54538fe58aaaba45698240 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:57:48 -0700 Subject: [PATCH 135/270] cabana: accept prefixed hex values in id filters (#38952) --- openpilot/tools/cabana/ui/widgets/messageswidget.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc index a3cacea680..635d1ee5b3 100644 --- a/openpilot/tools/cabana/ui/widgets/messageswidget.cc +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -23,11 +23,12 @@ namespace { const char *COLUMN_TITLES[MessageList::COLUMN_COUNT] = {"Name", "Bus", "ID", "Node", "Freq", "Count", "Bytes"}; constexpr float DEFAULT_SECTION_SIZE = 100.0f; -// surrounding whitespace is ignored; no sign, no 0x prefix +// surrounding whitespace is ignored; no sign, optional 0x prefix for hex unsigned int toUInt(const std::string &s, bool *ok, int base) { const char *b = s.data(), *e = b + s.size(); while (b < e && std::isspace((unsigned char)*b)) ++b; while (e > b && std::isspace((unsigned char)e[-1])) --e; + if (base == 16 && e - b >= 2 && b[0] == '0' && (b[1] == 'x' || b[1] == 'X')) b += 2; unsigned int v = 0; auto [p, ec] = std::from_chars(b, e, v, base); *ok = b < e && p == e && ec == std::errc(); From e77f6e56f9bbc1b16e14bac15a8cd376de049f9f Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:04:45 -0700 Subject: [PATCH 136/270] cabana: show cumulative byte and bit heatmaps (#38954) --- openpilot/tools/cabana/core/heatmap.h | 28 ++++++++++ openpilot/tools/cabana/tests/test_cabana.cc | 24 +++++++++ .../tools/cabana/ui/widgets/binaryview.cc | 53 ++++++++++--------- .../tools/cabana/ui/widgets/binaryview.h | 4 +- .../tools/cabana/ui/widgets/detailwidget.cc | 5 +- 5 files changed, 86 insertions(+), 28 deletions(-) create mode 100644 openpilot/tools/cabana/core/heatmap.h diff --git a/openpilot/tools/cabana/core/heatmap.h b/openpilot/tools/cabana/core/heatmap.h new file mode 100644 index 0000000000..04ef64e884 --- /dev/null +++ b/openpilot/tools/cabana/core/heatmap.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include + +// Count adjacent samples, never treating a newly appearing byte as a change. +struct HeatmapCounts { + HeatmapCounts() = default; + explicit HeatmapCounts(size_t size) : bits(size), bytes(size) {} + + void add(const uint8_t *data, size_t size) { + const size_t common = std::min({size, previous.size(), bits.size()}); + for (size_t i = 0; i < common; ++i) { + const uint8_t diff = data[i] ^ previous[i]; + if (diff) ++bytes[i]; + for (int bit = 0; bit < 8; ++bit) { + if (diff & (1u << bit)) ++bits[i][7 - bit]; + } + } + previous.assign(data, data + size); + } + + std::vector> bits; + std::vector bytes; + std::vector previous; +}; diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 268aa9a86d..5f6b8da31f 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -11,6 +11,7 @@ #include "common/tests/native_test.h" #include "tools/cabana/dbc/dbcfile.h" +#include "tools/cabana/core/heatmap.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/routes.h" #include "tools/cabana/ui/qtstate.h" @@ -413,7 +414,30 @@ void test_pixel_envelope() { REQUIRE(chart::pixelEnvelope(points.begin(), points.begin(), 0, 1, 10).empty()); } +void test_heatmap_counts() { + HeatmapCounts counts(3); + for (int value = 0; value < 256; ++value) { + const uint8_t data[] = {static_cast(value), 42, static_cast(value / 16)}; + counts.add(data, sizeof(data)); + } + REQUIRE(counts.bytes == std::vector({255, 0, 15})); + REQUIRE(counts.bits[0] == std::array({1, 3, 7, 15, 31, 63, 127, 255})); + REQUIRE(counts.bits[1] == std::array{}); + + // Every range starts with a baseline, without a transition from outside it. + HeatmapCounts range(3); + const uint8_t first[] = {255, 42, 15}; + range.add(first, 3); + REQUIRE(range.bytes == std::vector({0, 0, 0})); + const uint8_t short_data[] = {0}; + range.add(short_data, 1); + range.add(first, 3); + REQUIRE(range.bytes == std::vector({2, 0, 0})); + REQUIRE(HeatmapCounts(0).bits.empty()); +} + void test_cabana_core() { + test_heatmap_counts(); test_pixel_envelope(); test_format_seconds(); test_to_hex(); diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index 84f5a29fd8..0315279948 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -56,6 +56,13 @@ void fillBDiagPattern(ImDrawList *p, const ImRect &r, ImU32 col) { } // namespace BinaryView::BinaryView() { + heatmap_live_mode_ = can->liveStreaming(); + connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &events) { + if (events.count(msg_id_)) { + bit_flip_tracker_.valid = false; + if (!heatmap_live_mode_) updateState(); + } + })); connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); } @@ -63,6 +70,8 @@ BinaryView::BinaryView() { std::string BinaryView::whatsThis() const { return R"( Binary View
+ All / selected range: brighter bits and bytes changed more often; uncolored cells did not change.
+ Live: accumulated bit flips and fading byte changes during playback.
Shortcuts
Delete Signal:  x , @@ -359,6 +368,8 @@ void BinaryView::updateState() { cells_.resize(row_count_ * COLUMN_COUNT); } + for (auto &cell : cells_) cell.valid = false; + auto &bit_flips = heatmap_live_mode_ ? last_msg.bit_flip_counts : bitFlipChanges(binary.size()); uint32_t max_bit_flip_count = 1; // 1 to avoid division by zero for (const auto &row : bit_flips) { @@ -367,13 +378,16 @@ void BinaryView::updateState() { } } + uint32_t max_byte_flip_count = 1; + for (auto count : bit_flip_tracker_.counts.bytes) max_byte_flip_count = std::max(max_byte_flip_count, count); + const Palette &p = palette(); const double max_alpha = 255.0; const double min_alpha_with_signal = p.heatmap_signal_alpha; // Base alpha for small flip counts const double min_alpha_no_signal = p.heatmap_bit_alpha; // Base alpha for small flip counts for no signal bits const double alpha_gamma = p.heatmap_gamma; const double log_factor = 1.0 + 0.2; - const double log_scaler = max_alpha / log2(log_factor * max_bit_flip_count); + const double log_scaler = max_alpha / log2(1.0 + log_factor * max_bit_flip_count); for (size_t i = 0; i < binary.size(); ++i) { for (int j = 0; j < 8; ++j) { @@ -393,38 +407,27 @@ void BinaryView::updateState() { color.a = static_cast(alpha); setCell(i, j, bit_val, color); } - setCell(i, HEX_COLUMN, binary[i], last_msg.colors[i]); + auto byte_color = last_msg.colors[i]; + if (!heatmap_live_mode_) { + const auto count = bit_flip_tracker_.counts.bytes[i]; + const double intensity = std::log2(1.0 + count * log_factor) / std::log2(1.0 + max_byte_flip_count * log_factor); + byte_color = CabanaColor(102, 86, 169, static_cast(max_alpha * std::pow(intensity, alpha_gamma))); + } + setCell(i, HEX_COLUMN, binary[i], byte_color); } } const std::vector> &BinaryView::bitFlipChanges(size_t msg_size) { auto time_range = can->timeRange(); - if (bit_flip_tracker_.time_range == time_range && !bit_flip_tracker_.flip_counts.empty()) - return bit_flip_tracker_.flip_counts; + if (bit_flip_tracker_.valid && bit_flip_tracker_.time_range == time_range && + bit_flip_tracker_.counts.bits.size() == msg_size) return bit_flip_tracker_.counts.bits; bit_flip_tracker_.time_range = time_range; - bit_flip_tracker_.flip_counts.assign(msg_size, std::array{}); - + bit_flip_tracker_.counts = HeatmapCounts(msg_size); auto [first, last] = can->eventsInRange(msg_id_, time_range); - if (std::distance(first, last) <= 1) return bit_flip_tracker_.flip_counts; - - std::vector prev_values((*first)->dat, (*first)->dat + (*first)->size); - for (auto it = std::next(first); it != last; ++it) { - const CanEvent *event = *it; - int size = std::min(msg_size, event->size); - for (int i = 0; i < size; ++i) { - const uint8_t diff = event->dat[i] ^ prev_values[i]; - if (!diff) continue; - - auto &bit_flips = bit_flip_tracker_.flip_counts[i]; - for (int bit = 0; bit < 8; ++bit) { - if (diff & (1u << bit)) ++bit_flips[7 - bit]; - } - prev_values[i] = event->dat[i]; - } - } - - return bit_flip_tracker_.flip_counts; + for (auto it = first; it != last; ++it) bit_flip_tracker_.counts.add((*it)->dat, (*it)->size); + bit_flip_tracker_.valid = true; + return bit_flip_tracker_.counts.bits; } bool BinaryView::hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const { diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.h b/openpilot/tools/cabana/ui/widgets/binaryview.h index 1ca3c17bab..9e55b63fcd 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.h +++ b/openpilot/tools/cabana/ui/widgets/binaryview.h @@ -9,6 +9,7 @@ #include "imgui.h" #include "imgui_internal.h" +#include "tools/cabana/core/heatmap.h" #include "tools/cabana/core/observable.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" @@ -81,7 +82,8 @@ private: bool heatmap_live_mode_ = true; struct BitFlipTracker { std::optional> time_range; - std::vector> flip_counts; + HeatmapCounts counts; + bool valid = false; } bit_flip_tracker_; BinaryIndex anchor_index_; diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 830a51b889..28a7a5651e 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -48,6 +48,7 @@ DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { })); connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { tabbar_.removeTab(index); })); connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { showTabBarContextMenu(index); })); + heatmap_live_ = can->liveStreaming(); binary_view_ = std::make_unique(); signal_view_ = std::make_unique(charts); @@ -74,8 +75,8 @@ DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { char text[64]; if (range) snprintf(text, sizeof(text), "%.3f - %.3f", range->first, range->second); heatmap_all_text_ = range ? text : "All"; - const bool live = !range; - if (std::exchange(heatmap_live_, live) != live) binary_view_->setHeatmapLiveMode(live); + heatmap_live_ = !range && can->liveStreaming(); + binary_view_->setHeatmapLiveMode(heatmap_live_); })); } From b55e37b9c92c3364f3cab37cebc89ebe09d5db8b Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:05:10 -0700 Subject: [PATCH 137/270] cabana: right-align numeric message values (#38959) --- openpilot/tools/cabana/ui/widgets/messagebytes.cc | 4 ++-- openpilot/tools/cabana/ui/widgets/messagebytes.h | 2 +- openpilot/tools/cabana/ui/widgets/messageswidget.cc | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.cc b/openpilot/tools/cabana/ui/widgets/messagebytes.cc index 63e31d6f83..442f5e598f 100644 --- a/openpilot/tools/cabana/ui/widgets/messagebytes.cc +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.cc @@ -31,8 +31,8 @@ ImU32 cellTextColor(bool selected, bool inactive) { return ImGui::GetColorU32(inactive ? ImGuiCol_TextDisabled : ImGuiCol_Text); } -void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive) { - drawElidedText(dl, rect, text, cellTextColor(selected, inactive)); +void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive, bool align_right) { + drawElidedText(dl, rect, text, cellTextColor(selected, inactive), align_right); } void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vector &bytes, const std::vector *colors, diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.h b/openpilot/tools/cabana/ui/widgets/messagebytes.h index 7c7aabc984..163a5805ae 100644 --- a/openpilot/tools/cabana/ui/widgets/messagebytes.h +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.h @@ -15,6 +15,6 @@ ImVec2 byteCellSize(); // one "00 " cell ImVec2 bytesCellSize(int n, bool multiple_lines); // a cell of n bytes, the table's cell padding included ImU32 cellTextColor(bool selected, bool inactive); // inactive rows gray the text and fade the highlighted text -void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive); +void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive, bool align_right = false); void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vector &bytes, const std::vector *colors, bool selected, bool inactive, bool multiple_lines); diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc index 635d1ee5b3..e648113526 100644 --- a/openpilot/tools/cabana/ui/widgets/messageswidget.cc +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -506,7 +506,9 @@ void MessagesWidget::drawRow(int row) { if (column == MessageList::DATA && seen) { drawBytesCell(ImGui::GetWindowDrawList(), rect, m.dat, &m.colors, selected, inactive, multiple_lines); } else { - drawTextCell(ImGui::GetWindowDrawList(), rect, cellText(item, column), selected, inactive); + const bool align_right = seen && (column == MessageList::SOURCE || column == MessageList::COUNT || + (column == MessageList::FREQ && m.freq > 0)); + drawTextCell(ImGui::GetWindowDrawList(), rect, cellText(item, column), selected, inactive, align_right); } // the Selectable already sized its cell if (!row_item) ImGui::Dummy(ImVec2(width, row_height)); From 1090e2824b1dd31876caba794e12363827426054 Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:06:22 -0400 Subject: [PATCH 138/270] ui: add pairing provider to prime state (#38950) * changes * clean and lock sensible changes * don't write to disk every loop * clean * clean diff * clean indents --- openpilot/common/params_keys.h | 2 + openpilot/selfdrive/ui/lib/prime_state.py | 91 +++++++++++++++++++++++ openpilot/system/ui/README.md | 2 + 3 files changed, 95 insertions(+) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 4b87356aca..b9f3ffeaa7 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -111,6 +111,8 @@ inline static std::unordered_map keys = { {"OnroadCycleRequested", {CLEAR_ON_MANAGER_START, BOOL}}, {"OpenpilotEnabledToggle", {PERSISTENT, BOOL, "1"}}, {"PandaHeartbeatLost", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, + {"PairingEmail", {PERSISTENT, STRING}}, + {"PairingProvider", {PERSISTENT, STRING}}, {"PrimeType", {PERSISTENT, INT}}, {"RecordAudio", {PERSISTENT, BOOL}}, {"RecordFront", {PERSISTENT, BOOL}}, diff --git a/openpilot/selfdrive/ui/lib/prime_state.py b/openpilot/selfdrive/ui/lib/prime_state.py index 1fa87e31df..df6f18671e 100644 --- a/openpilot/selfdrive/ui/lib/prime_state.py +++ b/openpilot/selfdrive/ui/lib/prime_state.py @@ -22,6 +22,10 @@ class PrimeType(IntEnum): MAGENTA_NEW = 4 PURPLE = 5 +class Provider(str): + GOOGLE = "google" + GITHUB = "github" + APPLE = "apple" class PrimeState: FETCH_INTERVAL = 5.0 # seconds between API calls @@ -33,6 +37,11 @@ class PrimeState: self._lock = threading.Lock() self._session = requests.Session() # reuse session to reduce SSL handshake overhead self.prime_type: PrimeType = self._load_initial_state() + self._prime_trial_available = False + pairing_provider = os.getenv("PAIRING_PROVIDER") or self._params.get("PairingProvider") + self._pairing_provider: Provider | None = Provider(pairing_provider) if pairing_provider is not None else None + self._pairing_email: str | None = self._params.get("PairingEmail") + self._commacare = False self._running = False self._thread = None @@ -56,12 +65,38 @@ class PrimeState: response = api_get(f"v1.1/devices/{dongle_id}", timeout=self.API_TIMEOUT, access_token=identity_token, session=self._session) if response.status_code == 200: data = response.json() + is_paired = data.get("is_paired", False) prime_type = data.get("prime_type", 0) self.set_type(PrimeType(prime_type) if is_paired else PrimeType.UNPAIRED) + if not is_paired: + self.set_provider(None, None) + + prime_trial_available = data.get("trial_claimed") is False and data.get("eligible_features", {}).get("prime", False) + self.set_prime_trial_available(prime_trial_available) + + commacare = data.get("commacare", False) + self.set_commacare(commacare) except Exception as e: cloudlog.error(f"Failed to fetch prime status: {e}") + def _fetch_pairing_provider(self) -> None: + dongle_id = self._params.get("DongleId") + if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID: + return + + try: + identity_token = get_token(dongle_id) + response = api_get(f"v1/devices/{dongle_id}/owner", timeout=self.API_TIMEOUT, access_token=identity_token, session=self._session) + if response.status_code == 200: + data = response.json() + user_id = data.get("user_id", "") + provider = Provider(user_id.partition("_")[0]) + email = data.get("email") + self.set_provider(provider, email) + except Exception as e: + cloudlog.error(f"Failed to fetch pairing provider: {e}") + def set_type(self, prime_type: PrimeType) -> None: with self._lock: if prime_type != self.prime_type: @@ -69,12 +104,48 @@ class PrimeState: self._params.put("PrimeType", int(prime_type)) cloudlog.info(f"Prime type updated to {prime_type}") + def set_provider(self, provider: Provider | None, email: str | None): + with self._lock: + if self.prime_type <= PrimeType.UNPAIRED: + provider = None + email = None + email = email or None # if data.get(email) returns "" instead of None + + if self._pairing_provider != provider: + self._pairing_provider = provider + if provider is None: + self._params.remove("PairingProvider") + else: + self._params.put("PairingProvider", str(provider)) + + if self._pairing_email != email: + self._pairing_email = email + if email is None: + self._params.remove("PairingEmail") + else: + self._params.put("PairingEmail", email) + + def set_commacare(self, has_commacare: bool): + with self._lock: + if self.prime_type <= PrimeType.UNPAIRED: + self._commacare = False + else: + self._commacare = has_commacare + + def set_prime_trial_available(self, prime_trail_available: bool): + with self._lock: + if self.prime_type <= PrimeType.UNPAIRED: + self._prime_trial_available = False + else: + self._prime_trial_available = prime_trail_available + def _worker_thread(self) -> None: drop_realtime() from openpilot.selfdrive.ui.ui_state import ui_state, device while self._running: if not ui_state.started and device._awake: self._fetch_prime_status() + self._fetch_pairing_provider() for _ in range(int(self.FETCH_INTERVAL / self.SLEEP_INTERVAL)): if not self._running: @@ -109,5 +180,25 @@ class PrimeState: with self._lock: return self.prime_type > PrimeType.UNPAIRED + def can_claim_prime_trial(self) -> bool: + with self._lock: + return self._prime_trial_available + + def has_commacare(self) -> bool: + with self._lock: + return self._commacare + + def get_pairing_provider(self) -> str | None: + with self._lock: + return self._pairing_provider + + def get_pairing_account(self) -> str: + with self._lock: + if not self._pairing_provider: + return "unknown" + elif self._pairing_provider == Provider.GITHUB or not self._pairing_email: + return f"{self._pairing_provider} account" + return self._pairing_email + def __del__(self): self.stop() diff --git a/openpilot/system/ui/README.md b/openpilot/system/ui/README.md index 3624c8d1ce..6d1502be5a 100644 --- a/openpilot/system/ui/README.md +++ b/openpilot/system/ui/README.md @@ -11,6 +11,8 @@ Quick start: * set `GRID=50` to show a 50-pixel alignment grid overlay * set `MAGIC_DEBUG=1` to show every dropped frames (only on device) * set `RECORD=1` to record the screen, output defaults to `output.mp4` but can be set with `RECORD_OUTPUT` +* set `PRIME_TYPE` to preview pairing/Prime status: `-1` unpaired, `0` paired without Prime, `2` Prime Lite, `1` Prime Full +* set `PAIRING_PROVIDER` to preview the different states based on connect auth providers `google`, `github` or `apple` * https://www.raylib.com/cheatsheet/cheatsheet.html * https://electronstudio.github.io/raylib-python-cffi/README.html#quickstart From c504b92abadf75f170c950514e71a4f0d3690b80 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:07:01 -0700 Subject: [PATCH 139/270] cabana: clarify signal button states (#38953) * cabana: clarify plot button states * cabana: strengthen signal action hover states --- .../tools/cabana/ui/widgets/signalview.cc | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index bc8b9c4d23..921d4b26ab 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -909,35 +909,36 @@ void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - H_MARGIN - button * 2 - spacing, rect.Min.y + (rect.GetHeight() - button) * 0.5f)); - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); const auto sig = item->sig; const bool checked = item->chart_opened; const bool selected = current_sig_ == sig && current_type_ == SignalModel::Item::Sig; - auto row_button = [selected](const char *id, const char *glyph) { - if (selected) { - ImGui::PushStyleColor(ImGuiCol_Text, palette().text_selected); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, palette().header_active); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, palette().header_active); - } + auto row_button = [selected, button](const char *id, const char *glyph, bool plotted = false) { + const auto &p = palette(); + const ImVec2 button_min = ImGui::GetCursorScreenPos(); + const bool hovered = ImGui::IsWindowHovered() && + ImGui::IsMouseHoveringRect(button_min, ImVec2(button_min.x + button, button_min.y + button)); + // Both actions use neutral hover fills, including on selected rows. The plot keeps its outline. + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, plotted ? 1.0f : 0.0f); + ImGui::PushStyleColor(ImGuiCol_Button, plotted ? p.button : ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, settings.theme == LIGHT_THEME ? ImLerp(p.button, p.text, 0.12f) : p.button_hovered); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, settings.theme == LIGHT_THEME ? ImLerp(p.button, p.text, 0.20f) : p.button_active); + ImGui::PushStyleColor(ImGuiCol_Border, ImLerp(p.button, p.text, 0.45f)); + ImGui::PushStyleColor(ImGuiCol_Text, selected && !plotted && !hovered ? p.text_selected : p.text); const bool clicked = iconButton(id, glyph); - if (selected) ImGui::PopStyleColor(3); + ImGui::PopStyleColor(5); + ImGui::PopStyleVar(); return clicked; }; - if (checked) ImGui::PushStyleColor(ImGuiCol_Button, selected ? palette().header_active : ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); - if (row_button("plot", icon::GRAPH_UP) && !editor_open_on_press_) { + if (row_button("plot", icon::GRAPH_UP, checked) && !editor_open_on_press_) { item->chart_opened = !checked; showChart(model_.msgId(), sig, item->chart_opened, ImGui::GetIO().KeyShift); } - if (checked) ImGui::PopStyleColor(); ImGui::SetItemTooltip("%s", checked ? "Close Plot" : "Show Plot\nShift-click to add to the previously opened plot"); ImGui::SameLine(0.0f, spacing); if (row_button("remove", icon::X_LG) && !editor_open_on_press_) { pending_action_ = [this, sig]() { UndoStack::instance()->push(new RemoveSigCommand(model_.msgId(), sig)); }; } ImGui::SetItemTooltip("Remove signal"); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(); } ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions) { From 6c69ebed7f435747dc81777d86d2427b6fc7c601 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:09:33 -0700 Subject: [PATCH 140/270] cabana: improve heatmap readability (#38955) * cabana: improve heatmap readability * cabana: use shared colors for the heatmap * cabana: brighten dark theme text --- openpilot/tools/cabana/ui/theme.cc | 6 +--- openpilot/tools/cabana/ui/theme.h | 2 -- .../tools/cabana/ui/widgets/binaryview.cc | 31 ++++++++++--------- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc index 6a544ef05a..45212cb6d7 100644 --- a/openpilot/tools/cabana/ui/theme.cc +++ b/openpilot/tools/cabana/ui/theme.cc @@ -12,7 +12,7 @@ namespace fs = std::filesystem; namespace { constexpr Palette DARK_PALETTE = { - .text = rgb(0xdddddd), .text_disabled = rgb(0x7b7b7b), .text_selected = rgb(0xdddddd), + .text = rgb(0xffffff), .text_disabled = rgb(0x7b7b7b), .text_selected = rgb(0xffffff), .window = rgb(0x353535), .surface = rgb(0x2b2c2d), .frame = rgb(0x2b2c2d), .frame_hovered = rgb(0x363738), .frame_active = rgb(0x434445), .button = rgb(0x3f4041), .button_hovered = rgb(0x525354), .button_active = rgb(0x303132), @@ -22,8 +22,6 @@ constexpr Palette DARK_PALETTE = { .slider_track = rgb(0x7b7b7b), .tab = rgb(0x2c2c2c), .tab_hovered = rgb(0x444647), .tab_selected = rgb(0x393a3b), .table_header = rgb(0x3f4041), .grid = rgb(0xdddddd, 50.0f / 255.0f), .badge = rgb(0x808080), - .bit_background = rgb(0xffffff, 20.0f / 255.0f), - .heatmap_signal_alpha = 70.0, .heatmap_bit_alpha = 28.0, .heatmap_gamma = 0.6, .sparkline_saturation = 1.0f, .sparkline_value = 1.0f, }; @@ -38,8 +36,6 @@ constexpr Palette LIGHT_PALETTE = { .slider_track = rgb(0xd0d0d0), .tab = rgb(0xe5e5e5), .tab_hovered = rgb(0xefefef), .tab_selected = rgb(0xffffff), .table_header = rgb(0xefefef), .grid = rgb(0x000000, 50.0f / 255.0f), .badge = rgb(0xa0a0a4), - .bit_background = rgb(0xffffff, 0.0f), - .heatmap_signal_alpha = 25.0, .heatmap_bit_alpha = 10.0, .heatmap_gamma = 1.0, .sparkline_saturation = 2.0f, .sparkline_value = 0.7f, }; diff --git a/openpilot/tools/cabana/ui/theme.h b/openpilot/tools/cabana/ui/theme.h index e7346db6a6..0c565b55c7 100644 --- a/openpilot/tools/cabana/ui/theme.h +++ b/openpilot/tools/cabana/ui/theme.h @@ -18,8 +18,6 @@ struct Palette { ImVec4 tab, tab_hovered, tab_selected, table_header; ImVec4 grid; ImVec4 badge; // the fill behind the time labels drawn over a chart - ImVec4 bit_background; // overlay beneath heatmap bits without a signal - double heatmap_signal_alpha, heatmap_bit_alpha, heatmap_gamma; float sparkline_saturation, sparkline_value; // HSV multipliers for signal colors }; diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index 0315279948..9cb1a6ad10 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -16,6 +16,7 @@ namespace { const int CELL_HEIGHT = 36; +const float CELL_FONT_SIZE = UI_FONT_SIZE + 2.0f; const float SMALL_FONT_SIZE = 10.0f; // Inter needs 10 px for a 7 px cap height const int VERTICAL_HEADER_WIDTH = 30; inline int get_bit_pos(const BinaryIndex &index) { return flipBitPos(index.row * 8 + index.column); } @@ -122,9 +123,9 @@ void BinaryView::addShortcuts() { } ImVec2 BinaryView::minimumSizeHint() const { - // widest fixed-font glyph plus the header margins - pushMonoFont(); - const float min_section_size = ImGui::CalcTextSize("W").x + 8.0f; + // Match the enlarged hex font when reserving space for narrow panels. + pushMonoFont(CELL_FONT_SIZE); + const float min_section_size = std::ceil(ImGui::CalcTextSize("FF").x) + 10.0f; popMonoFont(); return {(min_section_size + 1) * 9 + VERTICAL_HEADER_WIDTH + 2, static_cast(CELL_HEIGHT * std::min(row_count_, 10) + 2)}; @@ -306,17 +307,20 @@ void BinaryView::draw() { const int rows = row_count_; // Keep hex bytes readable in narrow panels by scrolling instead of shrinking further. + pushMonoFont(CELL_FONT_SIZE); const float min_column_width = std::ceil(ImGui::CalcTextSize("FF").x) + 10.0f; + popMonoFont(); const float width = std::max(ImGui::GetContentRegionAvail().x, VERTICAL_HEADER_WIDTH + min_column_width * COLUMN_COUNT); column_width_ = std::max(min_column_width, (width - VERTICAL_HEADER_WIDTH) / COLUMN_COUNT); grid_pos_ = ImGui::GetCursorScreenPos(); ImGui::InvisibleButton("##binary_view", ImVec2(std::max(width, 1.0f), std::max(static_cast(rows * CELL_HEIGHT), 1.0f))); ImDrawList *painter = ImGui::GetWindowDrawList(); + painter->AddRectFilled(grid_pos_, ImVec2(grid_pos_.x + width, grid_pos_.y + rows * CELL_HEIGHT), paletteBase()); for (int row = 0; row < rows; ++row) { const ImRect r(grid_pos_.x, grid_pos_.y + row * CELL_HEIGHT, grid_pos_.x + VERTICAL_HEADER_WIDTH, grid_pos_.y + (row + 1) * CELL_HEIGHT); painter->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); // plain header background - drawText(painter, r, std::to_string(row).c_str(), ImGui::GetColorU32(ImGuiCol_Text)); + drawText(painter, r, std::to_string(row).c_str(), paletteText(true)); } for (int row = 0; row < rows; ++row) { for (int column = 0; column < COLUMN_COUNT; ++column) { @@ -381,11 +385,10 @@ void BinaryView::updateState() { uint32_t max_byte_flip_count = 1; for (auto count : bit_flip_tracker_.counts.bytes) max_byte_flip_count = std::max(max_byte_flip_count, count); - const Palette &p = palette(); + // Use the same logarithmic intensity mapping in both themes. const double max_alpha = 255.0; - const double min_alpha_with_signal = p.heatmap_signal_alpha; // Base alpha for small flip counts - const double min_alpha_no_signal = p.heatmap_bit_alpha; // Base alpha for small flip counts for no signal bits - const double alpha_gamma = p.heatmap_gamma; + const double min_alpha_with_signal = 25.0; // Base alpha for small flip counts + const double min_alpha_no_signal = 10.0; // Base alpha for small flip counts for no signal bits const double log_factor = 1.0 + 0.2; const double log_scaler = max_alpha / log2(1.0 + log_factor * max_bit_flip_count); @@ -398,7 +401,6 @@ void BinaryView::updateState() { uint32_t flip_count = bit_flips[i][j]; if (flip_count > 0) { double normalized_alpha = log2(1.0 + flip_count * log_factor) * log_scaler; - normalized_alpha = max_alpha * std::pow(std::clamp(normalized_alpha / max_alpha, 0.0, 1.0), alpha_gamma); double min_alpha = item.sigs.empty() ? min_alpha_no_signal : min_alpha_with_signal; alpha = std::clamp(normalized_alpha, min_alpha, max_alpha); } @@ -411,7 +413,7 @@ void BinaryView::updateState() { if (!heatmap_live_mode_) { const auto count = bit_flip_tracker_.counts.bytes[i]; const double intensity = std::log2(1.0 + count * log_factor) / std::log2(1.0 + max_byte_flip_count * log_factor); - byte_color = CabanaColor(102, 86, 169, static_cast(max_alpha * std::pow(intensity, alpha_gamma))); + byte_color = CabanaColor(102, 86, 169, static_cast(max_alpha * intensity)); } setCell(i, HEX_COLUMN, binary[i], byte_color); } @@ -441,12 +443,12 @@ bool BinaryView::hasSignal(const BinaryIndex &index, int dx, int dy, const caban void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index) const { auto item = &cellAt(index); ImFont *font = ImGui::GetFont(); - float font_size = ImGui::GetFontSize(); - ImU32 pen = paletteText(is_message_active_); + float font_size = CELL_FONT_SIZE; + ImU32 pen = paletteText(true); if (index.column == HEX_COLUMN) { if (item->valid) { - pushMonoFont(); + pushMonoFont(CELL_FONT_SIZE); font = ImGui::GetFont(); font_size = ImGui::GetFontSize(); popMonoFont(); @@ -454,7 +456,7 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary } } else if (isSelected(index)) { painter->AddRectFilled(rect.Min, rect.Max, resize_sig_ ? toImU32(resize_sig_->color) : paletteHighlight()); - if (resize_sig_) pen = IM_COL32_WHITE; + pen = IM_COL32_WHITE; } else if (!hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), resize_sig_) == item->sigs.end()) { // not resizing if (item->sigs.size() > 0) { for (auto &s : item->sigs) { @@ -465,7 +467,6 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary } } } else if (item->valid) { - painter->AddRectFilled(rect.Min, rect.Max, ImGui::GetColorU32(palette().bit_background)); if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); } bool bright = std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); From df7e0e5e77a41565fb4811787a42db8937dca2f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Thu, 17 Sep 2026 22:18:13 -0700 Subject: [PATCH 141/270] Use upstream tinygrad disk tensors for model loading (#38956) --- .../selfdrive/modeld/dmonitoringmodeld.py | 3 +-- openpilot/selfdrive/modeld/helpers.py | 24 +++++++------------ openpilot/selfdrive/modeld/modeld.py | 3 +-- .../modeld/models/big_driving_tinygrad.pkl | 4 ++-- tinygrad_repo | 2 +- 5 files changed, 13 insertions(+), 23 deletions(-) diff --git a/openpilot/selfdrive/modeld/dmonitoringmodeld.py b/openpilot/selfdrive/modeld/dmonitoringmodeld.py index 1ca9aab49b..5a6e9216ad 100755 --- a/openpilot/selfdrive/modeld/dmonitoringmodeld.py +++ b/openpilot/selfdrive/modeld/dmonitoringmodeld.py @@ -27,8 +27,7 @@ class ModelState: output: np.ndarray def __init__(self, cam_w: int, cam_h: int): - with open(MODEL_PKL_PATH, 'rb') as f: - jits = load_oob(f) + jits = load_oob(MODEL_PKL_PATH) self.DEV = jits['input_specs']['input_img'][2] self.input_shapes = jits['metadata']['input_shapes'] self.output_slices = pickle.loads(base64.b64decode(jits['metadata']['metadata']['output_slices'])) diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index bc5bc9e890..0d88e12a6a 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -1,8 +1,7 @@ -import mmap -import pickle -import struct +import sys from pathlib import Path +from openpilot.common.hardware import AGNOS from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH, is_chestnut_usb_id MODELS_DIR = Path(__file__).resolve().parent / 'models' @@ -12,19 +11,12 @@ def modeld_pkl_path(chestnut: bool): prefix = 'big_' if chestnut else '' return MODELS_DIR / f'{prefix}driving_tinygrad.pkl' -def load_oob(f): - data = memoryview(mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_COPY)) - opcode_size = struct.unpack_from(' len(data): - raise EOFError("incomplete model buffer") - yield pickle.PickleBuffer(data[offset:offset + size]) - offset += size - return pickle.loads(data[8:8 + opcode_size], buffers=buffers()) +def load_oob(path, chestnut=False): + from tinygrad import Context + device = 'USB+AMD:LLVM' if chestnut else 'QCOM' if AGNOS else 'METAL' if sys.platform == 'darwin' else 'CPU:LLVM' + with Context(DEV=device): + from tinygrad_repo.examples.openpilot.helpers import load_pickle + return load_pickle(path, out_of_band=True) def chestnut_present() -> bool: for d in USB_DEVICES_PATH.glob("*"): diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 72e42b0307..effb511e7d 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -139,8 +139,7 @@ class ModelState: prev_desire: np.ndarray # for tracking the rising edge of the pulse def __init__(self, cam_w: int, cam_h: int, chestnut: bool): - with open(modeld_pkl_path(chestnut), 'rb') as f: - jits = load_oob(f) + jits = load_oob(modeld_pkl_path(chestnut), chestnut) self.model_device = jits['input_specs']['new_img'][2] self.input_shapes = {name: (shape, np.dtype(dtype)) for name, (shape, dtype, _) in jits['input_specs'].items()} self.state_pairs = {name: f'next_{name}' for name in self.input_shapes if f'next_{name}' in jits['metadata']['output_shapes']} diff --git a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl index 3f479d5e42..2e3a751659 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e00a53cb9c64fe70a58224bc7879e5ea1b7e18baa7710f0929bf4f2e4537a468 -size 776612574 +oid sha256:a5fc6813f45be5673382d6571a5fc598a7cd8e47c534101fbc3a45767c169c53 +size 773082315 diff --git a/tinygrad_repo b/tinygrad_repo index 9fc9e60cb4..9d0446a4ba 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit 9fc9e60cb4126b36700269c44081fec0fad62655 +Subproject commit 9d0446a4ba8a532c8b674fb6ad795af015cd9dcf From c8d56a1990a3b12c6eeb2c0e44b34be2dd1d0394 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:28:30 -0700 Subject: [PATCH 142/270] cabana: filter multiplexed signals in binary grid (#38960) * cabana: filter multiplexed signals in binary grid * cabana: refresh overlap warnings on branch changes --- .../tools/cabana/ui/widgets/binarysignals.h | 32 ++++++++++ .../tools/cabana/ui/widgets/binaryview.cc | 63 +++++++++++-------- .../tools/cabana/ui/widgets/binaryview.h | 3 + .../tools/cabana/ui/widgets/detailwidget.cc | 1 + 4 files changed, 74 insertions(+), 25 deletions(-) create mode 100644 openpilot/tools/cabana/ui/widgets/binarysignals.h diff --git a/openpilot/tools/cabana/ui/widgets/binarysignals.h b/openpilot/tools/cabana/ui/widgets/binarysignals.h new file mode 100644 index 0000000000..1ccdd16c1c --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/binarysignals.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +#include "tools/cabana/dbc/dbc.h" + +// Only the current multiplexed branch owns the payload bits. Use this same +// signal set for painting, endpoint markers, and mouse interaction. +inline std::vector binaryViewSignals(const cabana::Msg *msg, const uint8_t *data, size_t size) { + if (!msg) return {}; + std::optional multiplex_value; + if (msg->multiplexor) { + auto selector = *msg->multiplexor; + if (std::max(selector.msb, selector.lsb) / 8 < size) { + // Multiplex values are raw identifiers, independent of display scaling. + selector.factor = 1; + selector.offset = 0; + selector.is_signed = false; + multiplex_value = get_raw_value(data, size, selector); + } + } + std::vector signals; + for (auto sig : msg->getSignals()) { + if (sig->type != cabana::Signal::Type::Multiplexed || + (multiplex_value && *multiplex_value == sig->multiplex_value)) { + signals.push_back(sig); + } + } + return signals; +} diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index 9cb1a6ad10..df17944447 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -10,6 +10,7 @@ #include "tools/cabana/commands.h" #include "tools/cabana/settings.h" #include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/widgets/binarysignals.h" #include "tools/cabana/utils/strings.h" #include "tools/cabana/utils/util.h" @@ -215,35 +216,46 @@ void BinaryView::refresh() { hovered_sig_ = nullptr; bit_flip_tracker_ = {}; cells_.clear(); - if (auto dbc_msg = dbc()->msg(msg_id_)) { - row_count_ = dbc_msg->size; - cells_.resize(row_count_ * COLUMN_COUNT); - for (auto sig : dbc_msg->getSignals()) { - for (int j = 0; j < sig->size; ++j) { - 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 >= cells_.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 ? cells_[idx].is_lsb = true : cells_[idx].is_msb = true; - if (j == sig->size - 1) sig->is_little_endian ? cells_[idx].is_msb = true : cells_[idx].is_lsb = true; - - auto &sigs = cells_[idx].sigs; - sigs.push_back(sig); - if (sigs.size() > 1) { - std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) { return l->size > r->size; }); - } - } - } - } else { - row_count_ = can->lastMessage(msg_id_).dat.size(); - cells_.resize(row_count_ * COLUMN_COUNT); - } + visible_signals_.clear(); + const auto *msg = dbc()->msg(msg_id_); + row_count_ = msg ? msg->size : can->lastMessage(msg_id_).dat.size(); + cells_.resize(row_count_ * COLUMN_COUNT); updateState(); if (under_mouse_) highlightPosition(last_mouse_pos_); } +void BinaryView::updateSignals() { + const auto &data = can->lastMessage(msg_id_).dat; + auto signals = binaryViewSignals(dbc()->msg(msg_id_), data.data(), data.size()); + if (signals == visible_signals_) return; + visible_signals_ = std::move(signals); + + // A branch switch invalidates the old hover and any in-progress resize. + selection_.clear(); + anchor_index_ = {}; + resize_sig_ = nullptr; + highlight(nullptr); + for (auto &cell : cells_) { + cell.sigs.clear(); + cell.is_msb = cell.is_lsb = false; + } + for (auto sig : visible_signals_) { + for (int j = 0; j < sig->size; ++j) { + 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 < 0 || idx >= cells_.size()) break; + if (j == 0) sig->is_little_endian ? cells_[idx].is_lsb = true : cells_[idx].is_msb = true; + if (j == sig->size - 1) sig->is_little_endian ? cells_[idx].is_msb = true : cells_[idx].is_lsb = true; + cells_[idx].sigs.push_back(sig); + } + } + for (auto &cell : cells_) { + std::stable_sort(cell.sigs.begin(), cell.sigs.end(), [](auto l, auto r) { return l->size > r->size; }); + } + if (under_mouse_) highlightPosition(last_mouse_pos_); + signalsChanged(); +} + std::set BinaryView::getOverlappingSignals() const { std::set overlapping; @@ -373,6 +385,7 @@ void BinaryView::updateState() { } for (auto &cell : cells_) cell.valid = false; + updateSignals(); auto &bit_flips = heatmap_live_mode_ ? last_msg.bit_flip_counts : bitFlipChanges(binary.size()); uint32_t max_bit_flip_count = 1; // 1 to avoid division by zero diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.h b/openpilot/tools/cabana/ui/widgets/binaryview.h index 9e55b63fcd..507d05811c 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.h +++ b/openpilot/tools/cabana/ui/widgets/binaryview.h @@ -40,6 +40,7 @@ public: std::string whatsThis() const; Observable signalClicked; + Observable<> signalsChanged; Observable signalHovered; Observable editSignal; Observable showChart; @@ -54,6 +55,7 @@ private: bool valid = false; }; + void updateSignals(); void refresh(); // rebuilds the grid from the DBC message void setCell(int row, int col, uint8_t val, const CabanaColor &color); const std::vector> &bitFlipChanges(size_t msg_size); @@ -78,6 +80,7 @@ private: MessageId msg_id_; std::vector cells_; + std::vector visible_signals_; int row_count_ = 0; bool heatmap_live_mode_ = true; struct BitFlipTracker { diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 28a7a5651e..abba2b607e 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -61,6 +61,7 @@ DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { updateState(); })); + connections_.push_back(binary_view_->signalsChanged.connect([this]() { refresh(); })); connections_.push_back(binary_view_->signalHovered.connect([this](const cabana::Signal *s) { signal_view_->signalHovered(s); })); connections_.push_back(binary_view_->signalClicked.connect([this](const cabana::Signal *s) { signal_view_->selectSignal(s, true); })); connections_.push_back(binary_view_->editSignal.connect([this](const cabana::Signal *origin_s, cabana::Signal &s) { signal_view_->saveSignal(origin_s, s); })); From c6d13eb723e0f6eee47ea7d56e47a700fba21380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Thu, 17 Sep 2026 22:44:49 -0700 Subject: [PATCH 143/270] Cinque v3 (#38932) Use f78ed37d for the precompiled eGPU driving model --- openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl index 2e3a751659..91125f3f21 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a5fc6813f45be5673382d6571a5fc598a7cd8e47c534101fbc3a45767c169c53 +oid sha256:4937fc6f04367b28328fe3bc0d1463a245725d039ddaab29406cc565fd5c8ed4 size 773082315 From 9a95fdc1a2b1ad2399923b2918f348eec12cfa90 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:56:42 -0700 Subject: [PATCH 144/270] cabana: fix stale message size warnings (#38961) --- .../tools/cabana/ui/widgets/detailwidget.cc | 22 +++++++++++++------ .../tools/cabana/ui/widgets/detailwidget.h | 1 + 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index abba2b607e..b73464c6e2 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -176,23 +176,30 @@ void DetailWidget::restoreTabs(const std::string &active_msg_id, const std::vect } void DetailWidget::refresh() { + auto msg = dbc()->msg(msg_id_); + std::string msg_name = msg ? msg->name + " (" + msg->transmitter + ")" : msgName(msg_id_); + name_label_.setText(msg_name); + name_label_.setToolTip(msg_name); + action_remove_msg_enabled_ = msg != nullptr; + updateWarnings(); +} + +void DetailWidget::updateWarnings() { std::vector warnings; auto msg = dbc()->msg(msg_id_); if (msg) { - if (msg_id_.source == INVALID_SOURCE) { + // A restored tab or a seek can refer to a bus with no sample at the current time. + // lastMessage() returns empty data in that case, not a received zero-byte frame. + if (msg_id_.source == INVALID_SOURCE || !can->lastMessages().count(msg_id_)) { warnings.push_back("No messages received."); } else if (msg->size != can->lastMessage(msg_id_).dat.size()) { - warnings.push_back("Message size (" + std::to_string(msg->size) + ") is incorrect."); + warnings.push_back("Message size mismatch: DBC defines " + std::to_string(msg->size) + + " bytes, received " + std::to_string(can->lastMessage(msg_id_).dat.size()) + " bytes."); } for (auto s : binary_view_->getOverlappingSignals()) { warnings.push_back(s->name + " has overlapping bits."); } } - std::string msg_name = msg ? msg->name + " (" + msg->transmitter + ")" : msgName(msg_id_); - name_label_.setText(msg_name); - name_label_.setToolTip(msg_name); - action_remove_msg_enabled_ = msg != nullptr; - if (!warnings.empty()) { warning_label_.clear(); for (size_t i = 0; i < warnings.size(); ++i) { @@ -212,6 +219,7 @@ void DetailWidget::updateState(const std::set *msgs) { binary_view_->updateState(); else history_log_->updateState(); + updateWarnings(); } void DetailWidget::editMsg(float parent_width) { diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.h b/openpilot/tools/cabana/ui/widgets/detailwidget.h index 4745ed5c84..6c64a33bf8 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.h +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.h @@ -75,6 +75,7 @@ private: void showTabBarContextMenu(int index); void editMsg(float parent_width); void updateState(const std::set *msgs = nullptr); + void updateWarnings(); MessageId msg_id_; const char *warning_icon_ = nullptr; From d06711be19b72688c6d8a4f485df98135352355e Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:58:15 -0400 Subject: [PATCH 145/270] ui: prime menu (#38860) * Show pairing provider and Prime status in device settings * remove unnecessary * simplify * bespoke human code * clean * readme * prime layout * sepearte qr code stuff * move icons * move qr code into its own widget * clean * Fix prime menu lint * Update widget leak test for prime menu * fix lint * update to new theme * change names * fix font size * get rid of subtitle * adjust commacare icon * drop manage, its cleaner * edit text * clean * for github and apple always return generic * updates * go back to original * noice * better overwrite * close on unpairing if in management section * missing 20 px when icon is there * clean diff * fix lint --- .../icons_mici/settings/device/apple.png | 3 + .../icons_mici/settings/device/commacare.png | 3 + .../icons_mici/settings/device/github.png | 3 + .../icons_mici/settings/device/google.png | 3 + .../icons_mici/settings/device/green_cell.png | 3 + .../settings/device/paired_apple.png | 3 + .../settings/device/paired_github.png | 3 + .../settings/device/paired_google.png | 3 + .../icons_mici/settings/device/phone.png | 3 + .../mici/layouts/settings/device/__init__.py | 0 .../{device.py => device/device_layout.py} | 89 +++------ .../ui/mici/layouts/settings/device/prime.py | 171 ++++++++++++++++++ .../ui/mici/layouts/settings/settings.py | 2 +- .../ui/mici/layouts/settings/software.py | 2 +- .../ui/mici/tests/test_widget_leaks.py | 8 +- openpilot/selfdrive/ui/mici/widgets/button.py | 5 +- openpilot/selfdrive/ui/mici/widgets/info.py | 16 +- 17 files changed, 241 insertions(+), 79 deletions(-) create mode 100644 openpilot/selfdrive/assets/icons_mici/settings/device/apple.png create mode 100644 openpilot/selfdrive/assets/icons_mici/settings/device/commacare.png create mode 100644 openpilot/selfdrive/assets/icons_mici/settings/device/github.png create mode 100644 openpilot/selfdrive/assets/icons_mici/settings/device/google.png create mode 100644 openpilot/selfdrive/assets/icons_mici/settings/device/green_cell.png create mode 100644 openpilot/selfdrive/assets/icons_mici/settings/device/paired_apple.png create mode 100644 openpilot/selfdrive/assets/icons_mici/settings/device/paired_github.png create mode 100644 openpilot/selfdrive/assets/icons_mici/settings/device/paired_google.png create mode 100644 openpilot/selfdrive/assets/icons_mici/settings/device/phone.png create mode 100644 openpilot/selfdrive/ui/mici/layouts/settings/device/__init__.py rename openpilot/selfdrive/ui/mici/layouts/settings/{device.py => device/device_layout.py} (78%) create mode 100644 openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/apple.png b/openpilot/selfdrive/assets/icons_mici/settings/device/apple.png new file mode 100644 index 0000000000..04af670a48 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/apple.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dfb645e59636687d61d20283bae65b68ddda6f7b2d4fda01a6e613a842e3b41f +size 823 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/commacare.png b/openpilot/selfdrive/assets/icons_mici/settings/device/commacare.png new file mode 100644 index 0000000000..0e484035cf --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/commacare.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df07383a6ddfe8cd7eeb2f22271cbd13068327ca0978873f8d10368860562e40 +size 111083 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/github.png b/openpilot/selfdrive/assets/icons_mici/settings/device/github.png new file mode 100644 index 0000000000..136c09aac7 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/github.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7518f3409e2ff8f7e99fb7f0c06afa8bdf0b0a506147b5cf9d4931b820a1a75 +size 1107 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/google.png b/openpilot/selfdrive/assets/icons_mici/settings/device/google.png new file mode 100644 index 0000000000..daeb574532 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/google.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a18e3de5f455b4089a824da8d5d78d5d532ef71facb05cc75c178b24d5beeb7 +size 920 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/green_cell.png b/openpilot/selfdrive/assets/icons_mici/settings/device/green_cell.png new file mode 100644 index 0000000000..99635bcc2b --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/green_cell.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:710e74b526adb5bbac15cd8915a828dd673dd8ce15b2fd694c64c182eef6f56f +size 13744 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/paired_apple.png b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_apple.png new file mode 100644 index 0000000000..75708bf51c --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_apple.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:547f466cc59d29647f3bf131d8444d7f22b63b186bcea55ceb8109c68ca74fe8 +size 2345 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/paired_github.png b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_github.png new file mode 100644 index 0000000000..8514100359 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_github.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:55050096cd98e7f4ea3e843d927c18815e458dfbd5e7d100b503db92075d061b +size 2373 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/paired_google.png b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_google.png new file mode 100644 index 0000000000..853de37d9d --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_google.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f9c439ed89bc0357e86c005d5dbb6f1ad2b8d72cdc0890e3201d842d8c3928f1 +size 2443 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/phone.png b/openpilot/selfdrive/assets/icons_mici/settings/device/phone.png new file mode 100644 index 0000000000..6114cd9124 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/phone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7aeccd3e0d8517f74eb34f7d73632c0b29b0ad5df5b9686c0f509bb6b136561e +size 1728 diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device/__init__.py b/openpilot/selfdrive/ui/mici/layouts/settings/device/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device.py b/openpilot/selfdrive/ui/mici/layouts/settings/device/device_layout.py similarity index 78% rename from openpilot/selfdrive/ui/mici/layouts/settings/device.py rename to openpilot/selfdrive/ui/mici/layouts/settings/device/device_layout.py index 0a665f7a2d..ff2e814b7d 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device/device_layout.py @@ -1,19 +1,16 @@ import os import pyray as rl -import time from collections.abc import Callable from typing import Union -from openpilot.common.api import Api -from openpilot.common.swaglog import cloudlog from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.time_helpers import system_time_valid +from openpilot.selfdrive.ui.mici.layouts.settings.device.prime import PrimeScroller from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller from openpilot.selfdrive.ui.mici.widgets.info import InfoLayoutMici -from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton, GreyBigButton +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog -from openpilot.selfdrive.ui.mici.widgets.qr import QR from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraDialog from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide, TermsPage from openpilot.system.ui.lib.application import gui_app, MousePos @@ -99,86 +96,50 @@ class DeviceInfoLayoutMici(InfoLayoutMici): super().__init__("device ID", params.get("DongleId") or 'N/A', "serial", params.get("HardwareSerial") or 'N/A', width=380) -class PairingDialog(NavScroller): - """Pair the device with comma connect.""" - - QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds - - def __init__(self): - super().__init__() - self._params = Params() - self._last_pairing_qr_generation = float("-inf") - self._qr = QR(self._get_pairing_url()) - self._scroller._show_scroll_indicator = False - self._scroller.add_widgets([ - self._qr, - GreyBigButton("finish setup", "scan to pair device\nwith connect", - gui_app.texture("icons_mici/settings/device/green_settings.png", 64, 64)), - GreyBigButton("", "connect lets you review recent driving footage and bookmark events."), - ]) - - def _get_pairing_url(self) -> str: - try: - dongle_id = self._params.get("DongleId") or "" - token = Api(dongle_id).get_token({'pair': True}) - except Exception as e: - cloudlog.warning(f"Failed to get pairing token: {e}") - token = "" - return f"https://connect.comma.ai/?pair={token}" - - def _update_layout_rects(self): - super()._update_layout_rects() - self._qr.set_rect(rl.Rectangle(self._qr.rect.x, self._qr.rect.y, self._rect.height, self._rect.height)) - - def _render(self, rect: rl.Rectangle): - current_time = time.monotonic() - if current_time - self._last_pairing_qr_generation >= self.QR_REFRESH_INTERVAL: - self._qr._url = self._get_pairing_url() - if self._qr._texture and self._qr._texture.id != 0: - rl.unload_texture(self._qr._texture) - self._qr._texture = self._qr._generate_qr_code() - self._last_pairing_qr_generation = current_time - super()._render(rect) - - def _update_state(self): - super()._update_state() - if ui_state.prime_state.is_paired() and not self.is_dismissing: - self.dismiss() - - class PairBigButton(BigButton): def __init__(self): - super().__init__("pair", "connect.comma.ai", gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60)) - - def _get_label_font_size(self): - return 64 + self._commacare_badge = gui_app.texture("icons_mici/settings/device/commacare.png", 27, 32) + self._show_commacare = False + self._comma_icon = gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60) + self._provider_icons = {provider: gui_app.texture(f"icons_mici/settings/device/paired_{provider}.png", 64, 64) + for provider in ("github", "google", "apple")} + super().__init__("pair to connect", "connect.comma.ai", self._comma_icon) def _update_state(self): super()._update_state() + self._show_commacare = (ui_state.prime_state.is_paired() and ui_state.prime_state.is_prime() + and ui_state.prime_state.has_commacare()) if ui_state.prime_state.is_paired(): + self.set_icon(self._provider_icons.get(ui_state.prime_state.get_pairing_provider(), self._comma_icon)) self.set_text("paired") if ui_state.prime_state.is_prime(): - self.set_value("subscribed") + self.set_value("prime" if ui_state.prime_state.is_full_prime() else "prime lite") else: - self.set_value("upgrade to prime") + self.set_value("claim prime trial" if ui_state.prime_state.can_claim_prime_trial() else "upgrade to prime") else: - self.set_text("pair") + self.set_icon(self._comma_icon) + self.set_text("pair to connect") self.set_value("connect.comma.ai") + def _draw_subtitle(self, rect: rl.Rectangle): + if self._show_commacare: + y = rect.y + rect.height - self._commacare_badge.height + rl.draw_texture_v(self._commacare_badge, rl.Vector2(rect.x, y), rl.WHITE) + offset = self._commacare_badge.width + 14 + rect = rl.Rectangle(rect.x + offset, rect.y, rect.width - offset, rect.height) + super()._draw_subtitle(rect) + def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) - # TODO: show ad dialog when clicked if not prime - if ui_state.prime_state.is_paired(): - return - dlg: BigDialog | PairingDialog + dlg: BigDialog | PrimeScroller if not system_time_valid(): dlg = BigDialog("", tr("Please connect to Wi-Fi to complete initial pairing.")) elif UNREGISTERED_DONGLE_ID == (ui_state.params.get("DongleId") or UNREGISTERED_DONGLE_ID): dlg = BigDialog("", tr("Device must be registered with the comma.ai backend to pair.")) else: - dlg = PairingDialog() + dlg = PrimeScroller() gui_app.push_widget(dlg) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py b/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py new file mode 100644 index 0000000000..2d4172a7b6 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py @@ -0,0 +1,171 @@ +import pyray as rl +import time + +from openpilot.common.api import Api +from openpilot.common.swaglog import cloudlog +from openpilot.common.params import Params +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, GreyBigButton +from openpilot.selfdrive.ui.mici.widgets.info import InfoLayoutMici +from openpilot.selfdrive.ui.mici.widgets.qr import QR +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets.scroller import NavScroller + + +class PairingInfoLayout(InfoLayoutMici): + def __init__(self): + super().__init__("account", "", "status", "") + self._commacare_badge = gui_app.texture("icons_mici/settings/device/commacare.png", 27, 32) + self._provider_icons = {provider: gui_app.texture(f"icons_mici/settings/device/{provider}.png", 32, 32) + for provider in ("github", "google", "apple")} + + def _update_state(self): + super()._update_state() + self.subtext1.set_text(ui_state.prime_state.get_pairing_account()) + self.subtext2.set_text(self._get_prime_status()) + self._provider_icon = self._provider_icons.get(ui_state.prime_state.get_pairing_provider()) + self._show_commacare = ui_state.prime_state.has_commacare() + + @staticmethod + def _get_prime_status() -> str: + if ui_state.prime_state.is_prime(): + return "prime" if ui_state.prime_state.is_full_prime() else "prime lite" + return "not subscribed" + + def _layout(self): + super()._layout() + provider_offset = self._provider_icon.width + 14 if self._provider_icon else 0 + commacare_offset = self._commacare_badge.width + 14 if self._show_commacare else 0 + self.subtext1.set_position(self.subtext1.rect.x + provider_offset, self.subtext1.rect.y) + self.subtext1.set_max_width(int(self._rect.width - provider_offset - (40 if self._provider_icon else 20))) + self.subtext2.set_position(self.subtext2.rect.x + commacare_offset, self.subtext2.rect.y) + self.subtext2.set_max_width(int(self._rect.width - commacare_offset - (40 if self._show_commacare else 20))) + + def _render(self, rect: rl.Rectangle): + super()._render(rect) + + if self._provider_icon: + icon_pos = rl.Vector2(self._rect.x + 20, self.subtext1.rect.y + (self.subtext1.rect.height - self._provider_icon.height) / 2) + rl.draw_texture_v(self._provider_icon, icon_pos, self._subheader_color) + if self._show_commacare: + icon_pos = rl.Vector2(self._rect.x + 20, self.subtext2.rect.y + (self.subtext2.rect.height - self._commacare_badge.height) / 2 + 4) + rl.draw_texture_v(self._commacare_badge, icon_pos, rl.WHITE) + + +class PrimeManagementScroller(NavScroller): + """Prime subscription management and upgrade information.""" + + def __init__(self): + super().__init__() + self._params = Params() + + can_claim_trial = ui_state.prime_state.can_claim_prime_trial() + + self._prime_icon = gui_app.texture("icons_mici/settings/device/green_cell.png", 64, 64) + self._phone_icon = gui_app.texture("icons_mici/settings/device/phone.png", 85, 64) + self._prime_adverts = [ + QR(self._get_prime_url()), + GreyBigButton( + "try prime for\n30 days" if can_claim_trial else "upgrade to prime", + "scan to claim trial" if can_claim_trial else "scan to manage\nprime status", + self._prime_icon + ), + GreyBigButton("", "prime adds 24/7 LTE to device and 1 year of cloud storage in connect.",), + GreyBigButton("", "prime lets you view live video and GPS location remotely in connect.",), + GreyBigButton("", "prime also includes commacare extended device warranty.",), + ] + self._prime_management = [ + QR(self._get_prime_url()), + GreyBigButton( + "manage prime", + "scan to open\ndevice prime settings", + self._phone_icon + ), + ] + self._scroller.add_widgets(self._prime_management if ui_state.prime_state.is_prime() else self._prime_adverts) + + def _get_prime_url(self) -> str: + if dongle_id := self._params.get("DongleId"): + return f"https://connect.comma.ai/{dongle_id}/prime" + return "https://connect.comma.ai" + + def _update_state(self): + super()._update_state() + if not ui_state.prime_state.is_paired(): + self.dismiss() + + +class PrimeScroller(NavScroller): + """Pairing flow or paired account information with access to prime management.""" + + QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds + + def __init__(self): + super().__init__() + self._params = Params() + self.initial_is_paired = ui_state.prime_state.is_paired() + self._last_pairing_qr_generation = float("-inf") + + self._manage_icon = gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60) + if not ui_state.prime_state.is_prime(): + title = "claim\nprime trial" if ui_state.prime_state.can_claim_prime_trial() else "upgrade\nto prime" + self._manage_prime = BigButton(title, icon=self._manage_icon) + else: + self._manage_prime = BigButton("manage prime", icon=self._manage_icon) + self._manage_prime.set_click_callback(lambda: gui_app.push_widget(PrimeManagementScroller())) + + if self.initial_is_paired: + self._scroller.add_widgets([ + PairingInfoLayout(), + self._manage_prime, + ]) + else: + self._qr = QR(self._get_pairing_url()) + self._scroller._show_scroll_indicator = False + self._scroller.add_widgets([ + self._qr, + GreyBigButton("finish setup", "scan to pair device\nwith connect", + gui_app.texture("icons_mici/settings/device/green_settings.png", 64, 64)), + GreyBigButton("", "connect lets you review recent driving footage and bookmark events."), + ]) + + def _get_pairing_url(self) -> str: + try: + dongle_id = self._params.get("DongleId") or "" + token = Api(dongle_id).get_token({'pair': True}) + except Exception as e: + cloudlog.warning(f"Failed to get pairing token: {e}") + token = "" + return f"https://connect.comma.ai/?pair={token}" + + def _update_layout_rects(self): + super()._update_layout_rects() + if not self.initial_is_paired: + self._qr.set_rect(rl.Rectangle(self._qr.rect.x, self._qr.rect.y, self._rect.height, self._rect.height)) + + def _render(self, rect: rl.Rectangle): + if not self.initial_is_paired: + current_time = time.monotonic() + if current_time - self._last_pairing_qr_generation >= self.QR_REFRESH_INTERVAL: + self._qr._url = self._get_pairing_url() + if self._qr._texture and self._qr._texture.id != 0: + rl.unload_texture(self._qr._texture) + self._qr._texture = self._qr._generate_qr_code() + self._last_pairing_qr_generation = current_time + super()._render(rect) + + def _update_state(self): + super()._update_state() + if self.initial_is_paired != ui_state.prime_state.is_paired() and gui_app.get_active_widget() is self: + self.dismiss() + + +if __name__ == "__main__": + gui_app.init_window("pairing device") + pairing = PrimeScroller() + gui_app.push_widget(pairing) + try: + for _ in gui_app.render(): + pass + finally: + del pairing diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py index 477bd78935..d49358297d 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py @@ -3,7 +3,7 @@ from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.network.network_layout import NetworkLayoutMici -from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici +from openpilot.selfdrive.ui.mici.layouts.settings.device.device_layout import DeviceLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.software import SoftwareLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 0c86d8e884..5c0f4cd6fe 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -5,7 +5,7 @@ from enum import IntEnum from collections.abc import Callable from openpilot.common.time_helpers import system_time_valid -from openpilot.selfdrive.ui.mici.layouts.settings.device import EngagedConfirmationButton +from openpilot.selfdrive.ui.mici.layouts.settings.device.device_layout import EngagedConfirmationButton from openpilot.selfdrive.ui.mici.widgets.info import InfoLayoutMici from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog diff --git a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py index a329a00e6b..71530f9fd8 100755 --- a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py +++ b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py @@ -19,7 +19,7 @@ KNOWN_LEAKS = { "openpilot.system.ui.widgets.button.Button", "openpilot.system.ui.widgets.html_render.HtmlRenderer", "openpilot.system.ui.widgets.nav_widget.NavBar", - "openpilot.selfdrive.ui.mici.layouts.settings.device.MiciFccModal", + "openpilot.selfdrive.ui.mici.layouts.settings.device.device_layout.MiciFccModal", "openpilot.system.ui.widgets.inputbox.InputBox", "openpilot.system.ui.widgets.scroller_tici.Scroller", "openpilot.system.ui.widgets.label.UnifiedLabel", @@ -52,9 +52,9 @@ class TestWidgetLeaks(OpenpilotTestCase): # mici dialogs from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide as MiciTrainingGuide, OnboardingWindow as MiciOnboardingWindow from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraDialog as MiciCabinCameraDialog - from openpilot.selfdrive.ui.mici.layouts.settings.device import PairingDialog as MiciPairingDialog + from openpilot.selfdrive.ui.mici.layouts.settings.device.prime import PrimeScroller from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigInputDialog - from openpilot.selfdrive.ui.mici.layouts.settings.device import MiciFccModal + from openpilot.selfdrive.ui.mici.layouts.settings.device.device_layout import MiciFccModal # tici dialogs from openpilot.selfdrive.ui.onroad.cabin_camera_dialog import CabinCameraDialog as TiciCabinCameraDialog @@ -71,7 +71,7 @@ class TestWidgetLeaks(OpenpilotTestCase): for ctor in ( # mici - MiciCabinCameraDialog, MiciPairingDialog, + MiciCabinCameraDialog, PrimeScroller, lambda: MiciTrainingGuide(lambda: None), lambda: MiciOnboardingWindow(lambda: None), lambda: BigDialog("test", "test"), diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index 4f395ae9bc..c481a7707b 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -241,6 +241,9 @@ class BigButton(BaseButton): btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2 return txt_bg, btn_x, btn_y, scale + def _draw_subtitle(self, rect: rl.Rectangle): + self._sub_label.render(rect) + def _draw_content(self, btn_y: float): # LABEL ------------------------------------------------------------------ label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING @@ -255,7 +258,7 @@ class BigButton(BaseButton): label_y = label_rect.y + self._label.get_content_height(int(label_rect.width)) sub_label_height = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING - label_y sub_label_rect = rl.Rectangle(label_x, label_y, self._subtitle_width_hint(), sub_label_height) - self._sub_label.render(sub_label_rect) + self._draw_subtitle(sub_label_rect) # ICON ------------------------------------------------------------------- if self._txt_icon: diff --git a/openpilot/selfdrive/ui/mici/widgets/info.py b/openpilot/selfdrive/ui/mici/widgets/info.py index 1b911d7a90..2d765cd6fd 100644 --- a/openpilot/selfdrive/ui/mici/widgets/info.py +++ b/openpilot/selfdrive/ui/mici/widgets/info.py @@ -10,22 +10,22 @@ class InfoLayoutMici(Widget): super().__init__() self.set_rect(rl.Rectangle(0, 0, width, 180)) - subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) max_width = int(self._rect.width - 20) + self._subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) self.title1 = UnifiedLabel(title1, 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) - self.subtext1 = UnifiedLabel(subtext1, 36, max_width=max_width, text_color=subheader_color, + self.subtext1 = UnifiedLabel(subtext1, 36, max_width=max_width, text_color=self._subheader_color, font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True) self.title2 = UnifiedLabel(title2, 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) - self.subtext2 = UnifiedLabel(subtext2, 36, max_width=max_width, text_color=subheader_color, + self.subtext2 = UnifiedLabel(subtext2, 36, max_width=max_width, text_color=self._subheader_color, font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True) - def _render(self, _): + def _layout(self): self.title1.set_position(self._rect.x + 20, self._rect.y - 10) - self.title1.render() self.subtext1.set_position(self._rect.x + 20, self._rect.y + 68 - 25) - self.subtext1.render() self.title2.set_position(self._rect.x + 20, self._rect.y + 114 - 30) - self.title2.render() self.subtext2.set_position(self._rect.x + 20, self._rect.y + 161 - 25) - self.subtext2.render() + + def _render(self, rect: rl.Rectangle): + for widget in [self.title1, self.subtext1, self.title2, self.subtext2]: + widget.render() From 7f6f13997c3c9b8e27e1581583f61e3fcabc5151 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Fri, 18 Sep 2026 06:44:39 -0700 Subject: [PATCH 146/270] replay: fix ranges with omitted start (#38963) * replay: fix ranges with omitted start * replay: keep range tests scoped to omitted start --- openpilot/tools/replay/.gitignore | 1 + openpilot/tools/replay/SConscript | 2 ++ openpilot/tools/replay/route.cc | 4 +-- openpilot/tools/replay/tests/test_route.cc | 37 ++++++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 openpilot/tools/replay/tests/test_route.cc diff --git a/openpilot/tools/replay/.gitignore b/openpilot/tools/replay/.gitignore index aa615770a2..720992de90 100644 --- a/openpilot/tools/replay/.gitignore +++ b/openpilot/tools/replay/.gitignore @@ -1,2 +1,3 @@ replay tests/test_replay +tests/test_route diff --git a/openpilot/tools/replay/SConscript b/openpilot/tools/replay/SConscript index 9e060bc829..f6c45b3792 100644 --- a/openpilot/tools/replay/SConscript +++ b/openpilot/tools/replay/SConscript @@ -14,3 +14,5 @@ replay_lib = replay_env.Library("replay", replay_lib_src, LIBS=base_libs, FRAMEW Export('replay_lib') replay_libs = [replay_lib] + ffmpeg_libs + ['ncurses'] + base_libs replay_env.Program("replay", ["main.cc"], LIBS=replay_libs, FRAMEWORKS=base_frameworks) +if GetOption('extras'): + replay_env.Program("tests/test_route", ["tests/test_route.cc"], LIBS=replay_libs, FRAMEWORKS=base_frameworks) diff --git a/openpilot/tools/replay/route.cc b/openpilot/tools/replay/route.cc index f38c2b1971..dce5661b4c 100644 --- a/openpilot/tools/replay/route.cc +++ b/openpilot/tools/replay/route.cc @@ -27,8 +27,8 @@ RouteIdentifier Route::parseRoute(const std::string &str) { const auto range_str = match[6].str(); if (!range_str.empty()) { if (separator == "/") { - int pos = range_str.find(':'); - int begin_seg = std::stoi(range_str.substr(0, pos)); + const auto pos = range_str.find(':'); + int begin_seg = pos == 0 ? 0 : std::stoi(range_str.substr(0, pos)); identifier.begin_segment = identifier.end_segment = begin_seg; if (pos != std::string::npos) { auto end_seg_str = range_str.substr(pos + 1); diff --git a/openpilot/tools/replay/tests/test_route.cc b/openpilot/tools/replay/tests/test_route.cc new file mode 100644 index 0000000000..c1eafbd4c5 --- /dev/null +++ b/openpilot/tools/replay/tests/test_route.cc @@ -0,0 +1,37 @@ +#include "common/tests/native_test.h" +#include "tools/replay/replay.h" +#include "tools/replay/route.h" + +void test_route_ranges() { + struct Range { + const char *suffix; + int begin; + int end; + }; + const Range ranges[] = { + {"", 0, -1}, + {"/:15", 0, 15}, + {"/:0", 0, 0}, + {"/0:15", 0, 15}, + {"/5:15", 5, 15}, + {"/5:", 5, -1}, + {"/5", 5, 5}, + {"--5", 5, -1}, + }; + for (const std::string route : {DEMO_ROUTE, "64e16c4237493597/00000033--332a5f9d82"}) { + const auto full = Route::parseRoute(route); + REQUIRE(!full.str.empty()); + for (const auto &range : ranges) { + const auto parsed = Route::parseRoute(route + range.suffix); + REQUIRE(parsed.str == full.str); + REQUIRE(parsed.dongle_id == full.dongle_id); + REQUIRE(parsed.timestamp == full.timestamp); + REQUIRE(parsed.begin_segment == range.begin); + REQUIRE(parsed.end_segment == range.end); + } + } +} + +int main() { + return run_native_test(test_route_ranges); +} From e691acbf34de063171d6f27335dc0efd73a01fc2 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Fri, 18 Sep 2026 11:21:39 -0700 Subject: [PATCH 147/270] chestnut stresstest (#38858) chestnut stress test --- Jenkinsfile | 44 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 92a26d6265..576daba734 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -161,6 +161,14 @@ def step(String name, String cmd, Map args = [:]) { return [name, cmd, args] } +def chestnutStage(String name = "chestnut") { + deviceStage(name, "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [ + step("build", "./openpilot/selfdrive/test/chestnut.sh"), + step("model replay", "openpilot/selfdrive/test/process_replay/model_replay.py --chestnut"), + step("onroad tests", "./openpilot/selfdrive/test/test_onroad.py TestChestnutOnroad", [timeout: 120]), + ]) +} + node { env.CI = "1" env.PYTHONWARNINGS = "error" @@ -171,6 +179,36 @@ node { env.GIT_BRANCH = checkout(scm).GIT_BRANCH env.GIT_COMMIT = checkout(scm).GIT_COMMIT + if (env.JOB_BASE_NAME == 'chestnut-stresstest') { + sh 'git fetch --no-tags origin nightly-chestnut' + env.GIT_COMMIT = sh(script: 'git show FETCH_HEAD:git_src_commit', returnStdout: true).trim() + if (!(env.GIT_COMMIT ==~ /[0-9a-f]{40}/)) { + error("invalid nightly-chestnut source commit") + } + def results = [] + try { + timeout(time: 12, unit: 'HOURS') { + for (int run = 1; run <= 100; run++) { + results << "run ${run}: INCOMPLETE" + try { + chestnutStage("chestnut ${run}/100") + results[run - 1] = "run ${run}: PASS" + } catch (hudson.AbortException e) { + results[run - 1] = "run ${run}: FAIL" + echo e.toString() + } + } + } + } finally { + writeFile file: 'chestnut_stress_report.txt', text: "commit: ${env.GIT_COMMIT}\ncompleted: ${results.count { !it.endsWith('INCOMPLETE') }}/100\n" + results.join('\n') + '\n' + archiveArtifacts artifacts: 'chestnut_stress_report.txt' + } + if (results.any { !it.endsWith('PASS') }) { + error("chestnut stress test failed") + } + return + } + def excludeBranches = ['__nightly', '__nightly-chestnut', 'devel', 'devel-staging', 'release-tizi', 'release-tizi-staging', 'release-mici', 'release-mici-staging', 'testing-closet*', 'hotfix-*'] def excludeRegex = excludeBranches.join('|').replaceAll('\\*', '.*') @@ -261,11 +299,7 @@ node { ]) }, 'chestnut': { - deviceStage("chestnut", "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [ - step("build", "./openpilot/selfdrive/test/chestnut.sh"), - step("model replay", "openpilot/selfdrive/test/process_replay/model_replay.py --chestnut"), - step("onroad tests", "./openpilot/selfdrive/test/test_onroad.py TestChestnutOnroad", [timeout: 120]), - ]) + chestnutStage() }, ) From 8abd1336370ba1c1f69094e48316e82fa61707e6 Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:44:45 -0400 Subject: [PATCH 148/270] offroad alerts: pairing notification (#38948) * Show pairing provider and Prime status in device settings * remove unnecessary * simplify * bespoke human code * clean * readme * prime layout * sepearte qr code stuff * move icons * move qr code into its own widget * clean * Fix prime menu lint * Update widget leak test for prime menu * fix lint * update to new theme * change names * fix font size * get rid of subtitle * adjust commacare icon * drop manage, its cleaner * edit text * clean * for github and apple always return generic * pairing notification * update copy and remove duplicates * don't write too often * remove * make sure it doesn't show up on 3X * reorg * remove redundancies --- openpilot/common/params_keys.h | 1 + .../selfdrive/selfdrived/alerts_offroad.json | 5 ++++ openpilot/selfdrive/ui/lib/prime_state.py | 4 +++ openpilot/selfdrive/ui/mici/layouts/main.py | 2 ++ .../ui/mici/layouts/offroad_alerts.py | 6 ++++ .../layouts/settings/device/device_layout.py | 29 ++++++++++++++++++- .../ui/mici/layouts/settings/device/prime.py | 2 +- .../ui/mici/layouts/settings/settings.py | 26 ++++++++++++++--- .../selfdrive/ui/widgets/offroad_alerts.py | 2 ++ 9 files changed, 71 insertions(+), 6 deletions(-) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index b9f3ffeaa7..96bef4daa7 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -103,6 +103,7 @@ inline static std::unordered_map keys = { {"Offroad_ConnectivityNeededPrompt", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_ExcessiveActuation", {PERSISTENT, JSON}}, {"Offroad_NoFirmware", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, + {"Offroad_Pairing", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_Recalibration", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_TemperatureTooHigh", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_UnregisteredHardware", {CLEAR_ON_MANAGER_START, JSON}}, diff --git a/openpilot/selfdrive/selfdrived/alerts_offroad.json b/openpilot/selfdrive/selfdrived/alerts_offroad.json index 33fec6e330..c5ee4650a3 100644 --- a/openpilot/selfdrive/selfdrived/alerts_offroad.json +++ b/openpilot/selfdrive/selfdrived/alerts_offroad.json @@ -65,5 +65,10 @@ "text": "Excessive %1 actuation detected on your last drive. Please contact support at https://comma.ai/support and share your device's Dongle ID for troubleshooting.", "severity": 1, "_comment": "Set extra field to lateral or longitudinal." + }, + "Offroad_Pairing": { + "text": "Finish setup. Pair your device with comma connect to view recent drives and bookmarked routes.", + "severity": -2, + "icon": "icons_mici/settings/device/green_settings.png" } } diff --git a/openpilot/selfdrive/ui/lib/prime_state.py b/openpilot/selfdrive/ui/lib/prime_state.py index df6f18671e..6b37675c9b 100644 --- a/openpilot/selfdrive/ui/lib/prime_state.py +++ b/openpilot/selfdrive/ui/lib/prime_state.py @@ -10,6 +10,7 @@ from openpilot.common.realtime import drop_realtime from openpilot.common.swaglog import cloudlog from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID from openpilot.selfdrive.ui.lib.api_helpers import get_token +from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert class PrimeType(IntEnum): @@ -43,6 +44,8 @@ class PrimeState: self._pairing_email: str | None = self._params.get("PairingEmail") self._commacare = False + set_offroad_alert("Offroad_Pairing", self.prime_type <= PrimeType.UNPAIRED) + self._running = False self._thread = None @@ -103,6 +106,7 @@ class PrimeState: self.prime_type = prime_type self._params.put("PrimeType", int(prime_type)) cloudlog.info(f"Prime type updated to {prime_type}") + set_offroad_alert("Offroad_Pairing", self.prime_type <= PrimeType.UNPAIRED) def set_provider(self, provider: Provider | None, email: str | None): with self._lock: diff --git a/openpilot/selfdrive/ui/mici/layouts/main.py b/openpilot/selfdrive/ui/mici/layouts/main.py index 5364e36da1..8d3ba17e95 100644 --- a/openpilot/selfdrive/ui/mici/layouts/main.py +++ b/openpilot/selfdrive/ui/mici/layouts/main.py @@ -70,6 +70,8 @@ class MiciMainLayout(Scroller): return self._body_onroad_layout if ui_state.is_body else self._car_onroad_layout def _setup_callbacks(self): + self._alerts_layout.set_enabled(lambda: self.enabled) + self._alerts_layout.set_pairing_callback(self._settings_layout.show_pairing) self._home_layout.set_callbacks( on_settings=lambda: gui_app.push_widget(self._settings_layout), on_alerts=lambda: self._scroll_to(self._alerts_layout), diff --git a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py index 6175a753bb..9ff7b7a7ba 100644 --- a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -4,6 +4,7 @@ import pyray as rl import re import threading import time +from collections.abc import Callable from dataclasses import dataclass from enum import IntEnum from openpilot.common.params import Params @@ -232,6 +233,11 @@ class MiciOffroadAlerts(Scroller): def scrolling(self): return self._scroller.scroll_panel.is_touch_valid() + def set_pairing_callback(self, callback: Callable[[], None]): + for alert_item in self.alert_items: + if alert_item.alert_data.key == "Offroad_Pairing": + alert_item.set_click_callback(callback) + def _build_alerts(self): """Build sorted list of alerts from OFFROAD_ALERTS.""" self.sorted_alerts = [] diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device/device_layout.py b/openpilot/selfdrive/ui/mici/layouts/settings/device/device_layout.py index ff2e814b7d..20b0523444 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device/device_layout.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device/device_layout.py @@ -148,6 +148,8 @@ class DeviceLayoutMici(NavScroller): super().__init__() self._fcc_dialog: MiciFccModal | None = None + self._pairing_button = PairBigButton() + self._pending_pairing_grow_animation = False def power_off_callback(): ui_state.params.put_bool("DoShutdown", True, block=True) @@ -192,7 +194,7 @@ class DeviceLayoutMici(NavScroller): self._scroller.add_widgets([ DeviceInfoLayoutMici(), - PairBigButton(), + self._pairing_button, review_training_guide_btn, cabin_cam_btn, terms_btn, @@ -202,6 +204,31 @@ class DeviceLayoutMici(NavScroller): self._power_off_btn, ]) + def _update_state(self): + super()._update_state() + if self._pending_pairing_grow_animation: + btn_right = self._pairing_button.rect.x + self._pairing_button.rect.width + visible_right = self._rect.x + self._rect.width + if btn_right < visible_right + 50: + self._pending_pairing_grow_animation = False + self._pairing_button.trigger_grow_animation() + + def show_event(self): + super().show_event() + if not ui_state.prime_state.is_paired(): + self.set_shown_callback(self._pairing_button.trigger_grow_animation) + + def hide_event(self): + super().hide_event() + self.set_shown_callback(None) + self._pending_pairing_grow_animation = False + + def scroll_to_pairing(self): + self._scroller._layout() + offset = (self._pairing_button.rect.x + self._pairing_button.rect.width / 2) - (self._rect.x + self._rect.width / 2) + self._scroller.scroll_to(offset, smooth=True, block_interrupt=True, block_widget_interaction=True) + self._pending_pairing_grow_animation = True + def _on_regulatory(self): if not self._fcc_dialog: self._fcc_dialog = MiciFccModal(os.path.join(BASEDIR, "openpilot/selfdrive/assets/offroad/mici_fcc.html")) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py b/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py index 2d4172a7b6..71a3984990 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py @@ -124,7 +124,7 @@ class PrimeScroller(NavScroller): self._scroller._show_scroll_indicator = False self._scroller.add_widgets([ self._qr, - GreyBigButton("finish setup", "scan to pair device\nwith connect", + GreyBigButton("finish setup", "scan QR code or visit connect.comma.ai", gui_app.texture("icons_mici/settings/device/green_settings.png", 64, 64)), GreyBigButton("", "connect lets you review recent driving footage and bookmark events."), ]) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py index d49358297d..c979fe67a5 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py @@ -28,9 +28,9 @@ class SettingsLayout(NavScroller): network_btn = SettingsBigButton("network", "", gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 76, 56)) network_btn.set_click_callback(lambda: gui_app.push_widget(network_panel)) - device_panel = DeviceLayoutMici() - device_btn = SettingsBigButton("device", "", gui_app.texture("icons_mici/settings/device_icon.png", 72, 58)) - device_btn.set_click_callback(lambda: gui_app.push_widget(device_panel)) + self._device_panel = DeviceLayoutMici() + self._device_button = SettingsBigButton("device", "", gui_app.texture("icons_mici/settings/device_icon.png", 72, 58)) + self._device_button.set_click_callback(lambda: gui_app.push_widget(self._device_panel)) software_panel = SoftwareLayoutMici() software_btn = SettingsBigButton("software", "", gui_app.texture("icons_mici/settings/software.png", 64, 75)) @@ -47,10 +47,28 @@ class SettingsLayout(NavScroller): self._scroller.add_widgets([ toggles_btn, network_btn, - device_btn, + self._device_button, software_btn, firehose_btn, developer_btn, ]) self._font_medium = gui_app.font(FontWeight.MEDIUM) + + def show_pairing(self): + gui_app.push_widget(self) + # add settings in the back stack without showing its entrance animation. + self._y_pos_filter.x = 0.0 + self.set_visible(lambda: self.enabled or self._device_panel.is_dismissing) + self.set_shown_callback(self._scroll_to_device) + gui_app.push_widget(self._device_panel) + self._device_panel.set_shown_callback(self._on_pairing_shown) + + def _scroll_to_device(self): + self._scroller._layout() + offset = (self._device_button.rect.x + self._device_button.rect.width / 2) - (self._rect.x + self._rect.width / 2) + self._scroller.scroll_to(offset, smooth=False) + + def _on_pairing_shown(self): + self.set_visible(True) + self._device_panel.scroll_to_pairing() diff --git a/openpilot/selfdrive/ui/widgets/offroad_alerts.py b/openpilot/selfdrive/ui/widgets/offroad_alerts.py index 88ccc5df7e..e2b62834ce 100644 --- a/openpilot/selfdrive/ui/widgets/offroad_alerts.py +++ b/openpilot/selfdrive/ui/widgets/offroad_alerts.py @@ -263,6 +263,8 @@ class OffroadAlert(AbstractAlert): def _build_alerts(self): self.sorted_alerts = [] for key, config in sorted(OFFROAD_ALERTS.items(), key=lambda x: x[1].get("severity", 0), reverse=True): + if key == "Offroad_Pairing": + continue severity = config.get("severity", 0) alert_data = AlertData(key=key, text="", severity=severity) self.sorted_alerts.append(alert_data) From a39561058f4270809c27628e37beb27ae41253ce Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:58:48 -0400 Subject: [PATCH 149/270] ui: allow delay of scroller start (#38958) * delay scroller after pairing QR code * clean --- .../selfdrive/ui/mici/layouts/settings/device/prime.py | 2 +- openpilot/system/ui/widgets/scroller.py | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py b/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py index 71a3984990..9a914dff4b 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py @@ -121,7 +121,7 @@ class PrimeScroller(NavScroller): ]) else: self._qr = QR(self._get_pairing_url()) - self._scroller._show_scroll_indicator = False + self._scroller.scroll_indicator_start_after = self._qr self._scroller.add_widgets([ self._qr, GreyBigButton("finish setup", "scan QR code or visit connect.comma.ai", diff --git a/openpilot/system/ui/widgets/scroller.py b/openpilot/system/ui/widgets/scroller.py index e79c4592fc..ebde01c428 100644 --- a/openpilot/system/ui/widgets/scroller.py +++ b/openpilot/system/ui/widgets/scroller.py @@ -97,6 +97,7 @@ class _Scroller(Widget): self._scroll_enabled: bool | Callable[[], bool] = True self._show_scroll_indicator = scroll_indicator and self._horizontal + self.scroll_indicator_start_after: Widget | None = None self._scroll_indicator = ScrollIndicator() self._edge_shadows = edge_shadows and self._horizontal @@ -364,7 +365,14 @@ class _Scroller(Widget): # Draw scroll indicator on top of edge shadows if self._show_scroll_indicator and len(self._visible_items) > 0: - self._scroll_indicator.update(self._scroll_offset, self._content_size, self._rect) + viewport = self._rect + if self.scroll_indicator_start_after is not None: + offset = min(self._rect.width, max(0.0, self.scroll_indicator_start_after.rect.x + + self.scroll_indicator_start_after.rect.width + + self._spacing - self._rect.x)) + viewport = rl.Rectangle(self._rect.x + offset, self._rect.y, self._rect.width - offset, self._rect.height) + + self._scroll_indicator.update(self._scroll_offset, self._content_size, viewport) self._scroll_indicator.render() def show_event(self): From 5ae0da0e465a21cb32b07eac0a987c24b2b7945e Mon Sep 17 00:00:00 2001 From: ZwX1616 Date: Fri, 18 Sep 2026 17:35:52 -0700 Subject: [PATCH 150/270] DM: Super Leicht Model (#38942) --- openpilot/cereal/log.capnp | 1 + openpilot/selfdrive/modeld/models/dmonitoring_model.onnx | 4 ++-- openpilot/selfdrive/monitoring/policy.py | 5 +++++ openpilot/selfdrive/monitoring/test_monitoring.py | 1 + openpilot/selfdrive/test/process_replay/model_replay.py | 1 + .../tools/jotpluggler/layouts/driver-monitoring-debug.json | 2 +- 6 files changed, 11 insertions(+), 3 deletions(-) diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index d8a761573c..ab53884d53 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -2235,6 +2235,7 @@ struct DriverMonitoringState { pose @0: Bool; eye @1: Bool; phone @2: Bool; + sleep @3: Bool; } struct Pose { diff --git a/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx b/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx index 1e592e2192..1d6527cf2c 100644 --- a/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx +++ b/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd299afabe7a3e0d04cbe2bd97fdb0c93bba8ad6d3cc3663a0e0ededaf243ac2 -size 7497335 +oid sha256:dee5a294e8afaacc9295ac5d100e00733ecac278e79264b96331e40a3ede1b04 +size 7844499 diff --git a/openpilot/selfdrive/monitoring/policy.py b/openpilot/selfdrive/monitoring/policy.py index 06aca55407..7e4b672d72 100644 --- a/openpilot/selfdrive/monitoring/policy.py +++ b/openpilot/selfdrive/monitoring/policy.py @@ -51,6 +51,7 @@ class DRIVER_MONITOR_SETTINGS: self._SG_THRESHOLD = 0.9 self._BLINK_THRESHOLD = 0.865 self._PHONE_THRESH = 0.5 + self._SLEEP_THRESH = 0.75 self._POSE_PITCH_THRESHOLD = 0.3133 self._POSE_PITCH_THRESHOLD_SLACK = 0.3237 self._POSE_PITCH_THRESHOLD_STRICT = self._POSE_PITCH_THRESHOLD @@ -138,6 +139,7 @@ class DriverMonitoring: self.pose = DriverPose(settings=self.settings) self.blink = DriverBlink() self.phone_prob = 0. + self.sleep_prob = 0. self.alert_level = AlertLevel.none self.always_on = always_on @@ -240,6 +242,7 @@ class DriverMonitoring: self.distracted_types['pose'] = bool((pitch_error > pitch_threshold) or (yaw_error > yaw_threshold)) self.distracted_types['eye'] = bool((self.blink.left + self.blink.right)*0.5 > self.settings._BLINK_THRESHOLD) self.distracted_types['phone'] = bool(self.phone_prob > self.settings._PHONE_THRESH) + self.distracted_types['sleep'] = bool(self.sleep_prob > self.settings._SLEEP_THRESH) def _update_states(self, driver_state, cal_rpy, car_speed, op_engaged, lowspeed, demo_mode=False, steering_angle_deg=0.): rhd_pred = driver_state.wheelOnRightProb @@ -277,6 +280,7 @@ class DriverMonitoring: self.blink.right = driver_data.rightBlinkProb * (driver_data.rightEyeProb > self.settings._EYE_THRESHOLD) \ * (driver_data.sunglassesProb < self.settings._SG_THRESHOLD) self.phone_prob = driver_data.phoneProb + self.sleep_prob = driver_data.sleepProb self._get_distracted_types() self.driver_distracted = any(self.distracted_types.values()) and driver_data.faceProb > self.settings._FACE_THRESHOLD and self.pose.low_std @@ -406,6 +410,7 @@ class DriverMonitoring: dm.visionPolicyState.distractedTypes.pose = self.distracted_types['pose'] dm.visionPolicyState.distractedTypes.eye = self.distracted_types['eye'] dm.visionPolicyState.distractedTypes.phone = self.distracted_types['phone'] + dm.visionPolicyState.distractedTypes.sleep = self.distracted_types['sleep'] dm.visionPolicyState.faceDetected = self.face_detected dm.visionPolicyState.pose.pitch = self.pose.pitch dm.visionPolicyState.pose.yaw = self.pose.yaw diff --git a/openpilot/selfdrive/monitoring/test_monitoring.py b/openpilot/selfdrive/monitoring/test_monitoring.py index 53e40f7019..18fd52f7b0 100644 --- a/openpilot/selfdrive/monitoring/test_monitoring.py +++ b/openpilot/selfdrive/monitoring/test_monitoring.py @@ -25,6 +25,7 @@ def make_msg(face_detected, distracted=False, model_uncertain=False): ds.leftDriverData.facePositionStd = [1.*model_uncertain, 1.*model_uncertain] # TODO: test both separately when e2e is used ds.leftDriverData.phoneProb = 0. + ds.leftDriverData.sleepProb = 0. return ds diff --git a/openpilot/selfdrive/test/process_replay/model_replay.py b/openpilot/selfdrive/test/process_replay/model_replay.py index fb46e83ef0..a4f9fcfb2b 100755 --- a/openpilot/selfdrive/test/process_replay/model_replay.py +++ b/openpilot/selfdrive/test/process_replay/model_replay.py @@ -80,6 +80,7 @@ def generate_report(proposed, master, tmp, commit): (lambda x: get_idx_if_non_empty(x.leftDriverData.faceOrientation, 0), "leftDriverData.faceOrientation0"), (lambda x: get_idx_if_non_empty(x.leftDriverData.leftBlinkProb), "leftDriverData.leftBlinkProb"), (lambda x: get_idx_if_non_empty(x.leftDriverData.phoneProb), "leftDriverData.phoneProb"), + (lambda x: get_idx_if_non_empty(x.leftDriverData.sleepProb), "leftDriverData.sleepProb"), (lambda x: get_idx_if_non_empty(x.rightDriverData.faceProb), "rightDriverData.faceProb"), ], "driverStateV2") diff --git a/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json b/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json index 3197f40ad8..e82edfbf96 100644 --- a/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json +++ b/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json @@ -1 +1 @@ -{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"children": [{"children": [{"camera_view": "driver", "curves": [], "kind": "camera", "title": "Cabin Camera"}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/alertLevel"}], "title": "..."}], "sizes": [0.5, 0.5], "split": "vertical"}, {"children": [{"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/activePolicy"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/faceDetected"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/eye"}, {"color": "#dc5234", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/phone"}, {"color": "#43a047", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/pose"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/awarenessPercent"}], "title": "..."}], "sizes": [0.25, 0.25, 0.25, 0.25], "split": "vertical"}], "sizes": [0.5, 0.5], "split": "horizontal"}}]} +{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"children": [{"children": [{"camera_view": "driver", "curves": [], "kind": "camera", "title": "Cabin Camera"}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/alertLevel"}], "title": "..."}], "sizes": [0.5, 0.5], "split": "vertical"}, {"children": [{"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/activePolicy"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/faceDetected"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/eye"}, {"color": "#dc5234", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/phone"}, {"color": "#8e44ad", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/sleep"}, {"color": "#43a047", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/pose"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/awarenessPercent"}], "title": "..."}], "sizes": [0.25, 0.25, 0.25, 0.25], "split": "vertical"}], "sizes": [0.5, 0.5], "split": "horizontal"}}]} From 2eee697f7d3ad80c599e0d08fa6d79c346c96968 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:47:49 -0700 Subject: [PATCH 151/270] cabana: improve theme contrast (#38965) * cabana: centralize accessible theme and data colors * cabana: remove color contrast tests * cabana: simplify theme color helpers * cabana: soften inactive message rows in dark mode * cabana: retain original signal tooltip behavior --- openpilot/tools/cabana/ui/chart/chart.cc | 14 +-- openpilot/tools/cabana/ui/chart/sparkline.cc | 5 +- openpilot/tools/cabana/ui/chart/sparkline.h | 2 +- openpilot/tools/cabana/ui/helpoverlay.cc | 4 +- openpilot/tools/cabana/ui/theme.cc | 101 +++++++++++++++--- openpilot/tools/cabana/ui/theme.h | 14 ++- openpilot/tools/cabana/ui/util.cc | 5 +- .../tools/cabana/ui/widgets/binaryview.cc | 33 +++--- .../tools/cabana/ui/widgets/detailwidget.cc | 2 +- .../tools/cabana/ui/widgets/messagebytes.cc | 13 +-- .../tools/cabana/ui/widgets/signalview.cc | 4 +- .../tools/cabana/ui/widgets/videowidget.cc | 18 ++-- 12 files changed, 150 insertions(+), 65 deletions(-) diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 1c319b4faf..57d00a2ee2 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -475,7 +475,7 @@ void ChartView::showTip(double sec) { std::string name = s.sig->name; std::string min = s.min == std::numeric_limits::max() ? "--" : utils::toString(s.min); std::string max = s.max == std::numeric_limits::lowest() ? "--" : utils::toString(s.max); - text_list.push_back({.has_marker = true, .marker = toImU32(s.color), .name = name, .value = value, .min = min, .max = max}); + text_list.push_back({.has_marker = true, .marker = toImU32(graphicColor(s.color)), .name = name, .value = value, .min = min, .max = max}); } } if (x < 0) { @@ -620,9 +620,9 @@ void ChartView::drawLegend() { ImGui::PopID(); if (series_type_ == SeriesType::Scatter) { - painter->AddCircleFilled(r.Min + ImVec2(marker_size / 2.0f, 2.0f + marker_size / 2.0f), marker_size / 2.0f, toImU32(s.color)); + painter->AddCircleFilled(r.Min + ImVec2(marker_size / 2.0f, 2.0f + marker_size / 2.0f), marker_size / 2.0f, toImU32(graphicColor(s.color))); } else { - drawColorMarker(painter, r.Min, toImU32(s.color)); + drawColorMarker(painter, r.Min, toImU32(graphicColor(s.color))); } float x = r.Min.x + marker_size + ImGui::GetStyle().ItemInnerSpacing.x; @@ -655,7 +655,7 @@ void ChartView::drawSeries() { const std::string label = "##sig" + std::to_string(i); ImPlotSpec spec; - spec.LineColor = toImVec4(s.color); + spec.LineColor = toImVec4(graphicColor(s.color)); spec.Stride = sizeof(ImPlotPoint); if (series_type_ == SeriesType::Scatter) { float radius = std::clamp(pixels_per_point / 2.0, 2.0, 8.0) / 2.0; @@ -682,7 +682,7 @@ void ChartView::drawSeries() { // show points when zoomed in enough if ((num_points == 1 || pixels_per_point > 20) && first != last) { ImPlotSpec dots; - dots.LineColor = toImVec4(s.color); + dots.LineColor = toImVec4(graphicColor(s.color)); dots.Stride = sizeof(ImPlotPoint); dots.Marker = ImPlotMarker_Circle; dots.MarkerSize = 4; @@ -700,12 +700,12 @@ void ChartView::drawForeground() { for (auto &s : sigs_) { if (!isNull(s.track_pt) && s.visible) { ImVec2 pos(xPos(s.track_pt.x), yPos(s.track_pt.y)); - painter->AddCircleFilled(pos, 5.5f, toImU32(s.color.darker(125))); + painter->AddCircleFilled(pos, 5.5f, toImU32(graphicColor(s.color))); track_line_x = std::max(track_line_x, pos.x); } } if (track_line_x > 0) { - const ImU32 dark_gray = IM_COL32(0x80, 0x80, 0x80, 0xff); + const ImU32 dark_gray = ImGui::GetColorU32(palette().text_disabled); for (float y = layout_.plot_area.Min.y; y < layout_.plot_area.Max.y; y += 8) { painter->AddLine(ImVec2(track_line_x, y), ImVec2(track_line_x, std::min(y + 4, layout_.plot_area.Max.y)), dark_gray, 1.0f); } diff --git a/openpilot/tools/cabana/ui/chart/sparkline.cc b/openpilot/tools/cabana/ui/chart/sparkline.cc index 1e804f2765..d9e5702fb5 100644 --- a/openpilot/tools/cabana/ui/chart/sparkline.cc +++ b/openpilot/tools/cabana/ui/chart/sparkline.cc @@ -100,7 +100,7 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi } size = sz; - color_ = toImU32(sparklineColor(color)); + color_ = color; draw_individual_points_ = draw_individual_points; window_end_ = window_end; xscale_ = xscale; @@ -108,7 +108,8 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos, ImU32 color) const { if (render_points_.empty()) return; - if (color == 0) color = color_; + // Pick up theme changes while paused. + if (color == 0) color = toImU32(sparklineColor(color_)); // update() only runs when a message of this id arrives, so a slow message would hold the sparkline // still for many frames and then move it in one step. scroll the rendered polyline by the time that diff --git a/openpilot/tools/cabana/ui/chart/sparkline.h b/openpilot/tools/cabana/ui/chart/sparkline.h index 991ecb8d4c..9d5420f4b6 100644 --- a/openpilot/tools/cabana/ui/chart/sparkline.h +++ b/openpilot/tools/cabana/ui/chart/sparkline.h @@ -26,7 +26,7 @@ private: std::vector points_; std::vector render_points_; - ImU32 color_ = 0; + CabanaColor color_; double window_end_ = 0; // the time the polyline was built for, so draw() can scroll it on from there double xscale_ = 0; bool draw_individual_points_ = false; diff --git a/openpilot/tools/cabana/ui/helpoverlay.cc b/openpilot/tools/cabana/ui/helpoverlay.cc index bc64f99196..f89e8b0b7e 100644 --- a/openpilot/tools/cabana/ui/helpoverlay.cc +++ b/openpilot/tools/cabana/ui/helpoverlay.cc @@ -181,7 +181,9 @@ void HelpOverlay::draw() { float x = min.x + 8.0f; for (const auto &r : line) { const float w = run_width(r); - const ImU32 color = r.color ? r.color : ImGui::GetColorU32(ImGuiCol_Text); + const auto background = fromImVec4(r.chip ? palette().button : palette().surface); + const auto source = r.color ? fromImVec4(ImGui::ColorConvertU32ToFloat4(r.color)) : fromImVec4(palette().text); + const ImU32 color = toImU32(contrastColor(source, background, r.swatch ? 3.0 : 4.5)); if (r.swatch) { dl->AddRectFilled(ImVec2(x + 2, y + 3), ImVec2(x + font_size - 2, y + font_size - 1), color); } else { diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc index 45212cb6d7..20cf2919ff 100644 --- a/openpilot/tools/cabana/ui/theme.cc +++ b/openpilot/tools/cabana/ui/theme.cc @@ -12,30 +12,30 @@ namespace fs = std::filesystem; namespace { constexpr Palette DARK_PALETTE = { - .text = rgb(0xffffff), .text_disabled = rgb(0x7b7b7b), .text_selected = rgb(0xffffff), + .text = rgb(0xffffff), .text_disabled = rgb(0xcccccc), .text_inactive = rgb(0xa4a4a4), .text_selected = rgb(0xffffff), .window = rgb(0x353535), .surface = rgb(0x2b2c2d), .frame = rgb(0x2b2c2d), .frame_hovered = rgb(0x363738), .frame_active = rgb(0x434445), .button = rgb(0x3f4041), .button_hovered = rgb(0x525354), .button_active = rgb(0x303132), - .header = rgb(0x2d74d2), .header_hovered = rgb(0x323944), .header_active = rgb(0x2d74d2), - .accent = rgb(0x2d74d2), - .border = rgb(0x606060), .separator = rgb(0x353535), .scrollbar_grab = rgb(0x4c4e4f), - .slider_track = rgb(0x7b7b7b), + .header = rgb(0x2d74d2), .header_hovered = rgb(0x323944), .header_active = rgb(0x434445), + .accent = rgb(0x90bfff), + .border = rgb(0xaaaaaa), .separator = rgb(0x353535), .scrollbar_grab = rgb(0xaaaaaa), + .slider_track = rgb(0xaaaaaa), .tab = rgb(0x2c2c2c), .tab_hovered = rgb(0x444647), .tab_selected = rgb(0x393a3b), .table_header = rgb(0x3f4041), - .grid = rgb(0xdddddd, 50.0f / 255.0f), .badge = rgb(0x808080), + .grid = rgb(0xdddddd, 50.0f / 255.0f), .badge = rgb(0x626262), .sparkline_saturation = 1.0f, .sparkline_value = 1.0f, }; constexpr Palette LIGHT_PALETTE = { - .text = rgb(0x000000), .text_disabled = rgb(0xbebebe), .text_selected = rgb(0xffffff), + .text = rgb(0x000000), .text_disabled = rgb(0x595959), .text_inactive = rgb(0x595959), .text_selected = rgb(0xffffff), .window = rgb(0xefefef), .surface = rgb(0xffffff), .frame = rgb(0xffffff), .frame_hovered = rgb(0xf5f9fc), .frame_active = rgb(0xe7f3fb), .button = rgb(0xefefef), .button_hovered = rgb(0xe7f3fb), .button_active = rgb(0xd4e7f4), - .header = rgb(0x308cc6), .header_hovered = rgb(0xe7f3fb), .header_active = rgb(0x308cc6), - .accent = rgb(0x308cc6), - .border = rgb(0xb6b6b6), .separator = rgb(0xd0d0d0), .scrollbar_grab = rgb(0xb6b6b6), - .slider_track = rgb(0xd0d0d0), + .header = rgb(0x226a99), .header_hovered = rgb(0xe7f3fb), .header_active = rgb(0xd4e7f4), + .accent = rgb(0x226a99), + .border = rgb(0x747474), .separator = rgb(0xd0d0d0), .scrollbar_grab = rgb(0x747474), + .slider_track = rgb(0x747474), .tab = rgb(0xe5e5e5), .tab_hovered = rgb(0xefefef), .tab_selected = rgb(0xffffff), .table_header = rgb(0xefefef), - .grid = rgb(0x000000, 50.0f / 255.0f), .badge = rgb(0xa0a0a4), + .grid = rgb(0x000000, 50.0f / 255.0f), .badge = rgb(0x626262), .sparkline_saturation = 2.0f, .sparkline_value = 0.7f, }; @@ -67,6 +67,36 @@ ImFont *addFont(const fs::path &path, float size) { return font; } +double linearChannel(double value) { + value /= 255.0; + return value <= 0.04045 ? value / 12.92 : std::pow((value + 0.055) / 1.055, 2.4); +} + +double luminance(const CabanaColor &color) { + return 0.2126 * linearChannel(color.r) + 0.7152 * linearChannel(color.g) + 0.0722 * linearChannel(color.b); +} + +// Directed rounding preserves the luminance bound in 8-bit sRGB. +CabanaColor boundLuminance(const CabanaColor &color, double bound, bool minimum) { + const double current = luminance(color); + if (minimum ? current >= bound : current <= bound) return color; + auto channel = [&](uint8_t value) { + double c = linearChannel(value); + c = minimum ? c + (1.0 - c) * (bound - current) / (1.0 - current) : c * bound / current; + const double srgb = c <= 0.0031308 ? 12.92 * c : 1.055 * std::pow(c, 1.0 / 2.4) - 0.055; + const double scaled = std::clamp(srgb * 255.0, 0.0, 255.0); + return static_cast(minimum ? std::ceil(scaled) : std::floor(scaled)); + }; + return {channel(color.r), channel(color.g), channel(color.b), color.a}; +} + +CabanaColor composite(const CabanaColor &color, const CabanaColor &base) { + auto channel = [&](int foreground, int background) { + return static_cast(std::lround((foreground * color.a + background * (255 - color.a)) / 255.0)); + }; + return {channel(color.r, base.r), channel(color.g, base.g), channel(color.b, base.b)}; +} + ImVec4 alpha(ImVec4 c, float a) { return ImVec4(c.x, c.y, c.z, a); } } // namespace @@ -123,7 +153,8 @@ void applyTheme(int theme) { c[ImGuiCol_FrameBgHovered] = p.frame_hovered; c[ImGuiCol_FrameBgActive] = p.frame_active; c[ImGuiCol_Button] = p.button; - c[ImGuiCol_ButtonHovered] = c[ImGuiCol_SliderGrab] = p.button_hovered; + c[ImGuiCol_ButtonHovered] = p.button_hovered; + c[ImGuiCol_SliderGrab] = p.accent; c[ImGuiCol_ScrollbarGrab] = p.scrollbar_grab; c[ImGuiCol_ScrollbarGrabHovered] = p.text_disabled; c[ImGuiCol_ButtonActive] = p.button_active; @@ -157,10 +188,52 @@ void applyTheme(int theme) { const Palette &palette() { return *g_palette; } +CabanaColor contrastColor(CabanaColor color, const CabanaColor &background, double target) { + color.a = 255; + const double value = luminance(color), base = luminance(background); + if ((std::max(value, base) + 0.05) / (std::min(value, base) + 0.05) >= target) return color; + const bool lighter = base < 0.179; + const double bound = lighter ? target * (base + 0.05) - 0.05 : (base + 0.05) / target - 0.05; + return boundLuminance(color, std::clamp(bound, 0.0, 1.0), lighter); +} + +CabanaColor byteColor(const CabanaColor &color) { + const bool dark = palette().text.x > 0.5f; + const auto [h, s, v] = color.hsv(); + auto fill = CabanaColor::fromHsv(h, std::min(s, 0.30f), 0.86f, color.alphaF()); + if (dark) fill = boundLuminance(fill, 0.16, false); // 5:1 with white text + fill = composite(fill, fromImVec4(palette().surface)); + return dark ? fill : composite({255, 255, 255, 40}, fill); +} + +CabanaColor signalFill(const CabanaColor &color, bool defined) { + const bool dark = palette().text.x > 0.5f; + auto fill = composite(color, fromImVec4(palette().surface)); + fill = composite(dark ? CabanaColor(0, 0, 0, 115) : CabanaColor(255, 255, 255, defined ? 20 : 40), fill); + return boundLuminance(fill, 0.18, !dark); // 4.5:1 with the theme's text +} + +CabanaColor signalHighlight(CabanaColor color) { + color.a = 255; + return boundLuminance(boundLuminance(color, 0.175, true), 0.18, false); +} + +CabanaColor signalOutline(CabanaColor color, bool hovered) { + const bool dark = palette().text.x > 0.5f; + if (dark && hovered) color = color.lighter(150); + color.a = 255; + // 3:1 against normal and highlighted fills, including neighboring signals. + return dark ? boundLuminance(color, 0.67, true) : boundLuminance(color, 0.02, false); +} + +CabanaColor graphicColor(const CabanaColor &color, const ImVec4 &background) { + return contrastColor(color, fromImVec4(background), 3.0); +} + CabanaColor sparklineColor(const CabanaColor &color) { const Palette &p = palette(); auto [h, s, v] = color.hsv(); - return CabanaColor::fromHsv(h, std::min(1.0f, s * p.sparkline_saturation), v * p.sparkline_value, color.a / 255.0f); + return graphicColor(CabanaColor::fromHsv(h, std::min(1.0f, s * p.sparkline_saturation), v * p.sparkline_value)); } ImFont *boldFont() { return g_bold_font; } diff --git a/openpilot/tools/cabana/ui/theme.h b/openpilot/tools/cabana/ui/theme.h index 0c565b55c7..4655219505 100644 --- a/openpilot/tools/cabana/ui/theme.h +++ b/openpilot/tools/cabana/ui/theme.h @@ -6,12 +6,12 @@ #include "tools/cabana/core/color.h" struct Palette { - ImVec4 text, text_disabled, text_selected; + ImVec4 text, text_disabled, text_inactive, text_selected; ImVec4 window; // the background behind panels and docked windows ImVec4 surface; // panels, popups, table bodies: what content is drawn on ImVec4 frame, frame_hovered, frame_active; ImVec4 button, button_hovered, button_active; - ImVec4 header, header_hovered, header_active; // selections + ImVec4 header, header_hovered, header_active; // selected fill; unselected hover/press fills ImVec4 accent; ImVec4 border, separator, scrollbar_grab; ImVec4 slider_track; @@ -28,6 +28,10 @@ inline ImVec4 colorRgb(int r, int g, int b, float alpha = 1.0f) { return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, alpha); } inline ImU32 toImU32(const CabanaColor &c) { return IM_COL32(c.r, c.g, c.b, c.a); } +inline CabanaColor fromImVec4(const ImVec4 &c) { + return {static_cast(std::lround(c.x * 255)), static_cast(std::lround(c.y * 255)), + static_cast(std::lround(c.z * 255)), static_cast(std::lround(c.w * 255))}; +} inline ImVec4 toImVec4(const CabanaColor &c) { return ImVec4(c.r / 255.0f, c.g / 255.0f, c.b / 255.0f, c.a / 255.0f); } inline ImU32 withAlpha(ImU32 c, int alpha) { return (c & ~IM_COL32_A_MASK) | ((ImU32)alpha << IM_COL32_A_SHIFT); } @@ -44,6 +48,12 @@ constexpr float UI_FONT_SIZE = 16.0f; void loadFonts(); void applyTheme(int theme); // Safe to call at runtime. const Palette &palette(); +CabanaColor contrastColor(CabanaColor color, const CabanaColor &background, double target = 4.5); +CabanaColor byteColor(const CabanaColor &color); +CabanaColor signalFill(const CabanaColor &color, bool defined = true); +CabanaColor signalHighlight(CabanaColor color); +CabanaColor signalOutline(CabanaColor color, bool hovered = false); +CabanaColor graphicColor(const CabanaColor &color, const ImVec4 &background = palette().surface); CabanaColor sparklineColor(const CabanaColor &color); ImFont *boldFont(); diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index b11ecf28fa..2252355144 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -94,9 +94,10 @@ bool selectable(const char *label, bool selected, ImGuiSelectableFlags flags, co if (selected) { ImGui::PushStyleColor(ImGuiCol_Text, palette().text_selected); ImGui::PushStyleColor(ImGuiCol_HeaderHovered, palette().header); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, palette().header); } const bool clicked = ImGui::Selectable(label, selected, flags, size); - if (selected) ImGui::PopStyleColor(2); + if (selected) ImGui::PopStyleColor(3); return clicked; } @@ -382,7 +383,7 @@ int tableHeadersRow() { bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size) { ImGui::PushStyleColor(ImGuiCol_HeaderHovered, selected ? ImGui::GetColorU32(ImGuiCol_Header) : IM_COL32(0, 0, 0, 0)); - ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImGui::GetColorU32(ImGuiCol_Header)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, selected ? palette().header : palette().header_active); const bool clicked = selectable(label, selected, flags, size); ImGui::PopStyleColor(2); return clicked; diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index df17944447..9d983b918d 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -22,10 +22,8 @@ const float SMALL_FONT_SIZE = 10.0f; // Inter needs 10 px for a 7 px cap height const int VERTICAL_HEADER_WIDTH = 30; inline int get_bit_pos(const BinaryIndex &index) { return flipBitPos(index.row * 8 + index.column); } -inline ImU32 paletteHighlight() { return ImGui::GetColorU32(ImGuiCol_Header); } inline ImU32 paletteBase() { return ImGui::GetColorU32(ImGuiCol_ChildBg); } -inline ImU32 paletteText(bool active) { return ImGui::GetColorU32(active ? ImGuiCol_Text : ImGuiCol_TextDisabled); } -const ImU32 DARK_GRAY = IM_COL32(128, 128, 128, 255); +inline ImU32 paletteText() { return ImGui::GetColorU32(ImGuiCol_Text); } // JetBrains Mono ships no bold variant, so emulate one by drawing the glyphs again a fraction of a // pixel to the right. Keeps the monospace advance, unlike switching to the proportional bold face. @@ -332,7 +330,7 @@ void BinaryView::draw() { for (int row = 0; row < rows; ++row) { const ImRect r(grid_pos_.x, grid_pos_.y + row * CELL_HEIGHT, grid_pos_.x + VERTICAL_HEADER_WIDTH, grid_pos_.y + (row + 1) * CELL_HEIGHT); painter->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); // plain header background - drawText(painter, r, std::to_string(row).c_str(), paletteText(true)); + drawText(painter, r, std::to_string(row).c_str(), paletteText()); } for (int row = 0; row < rows; ++row) { for (int column = 0; column < COLUMN_COUNT; ++column) { @@ -457,7 +455,7 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary auto item = &cellAt(index); ImFont *font = ImGui::GetFont(); float font_size = CELL_FONT_SIZE; - ImU32 pen = paletteText(true); + ImU32 pen = paletteText(); if (index.column == HEX_COLUMN) { if (item->valid) { @@ -465,31 +463,30 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary font = ImGui::GetFont(); font_size = ImGui::GetFontSize(); popMonoFont(); - painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + painter->AddRectFilled(rect.Min, rect.Max, toImU32(byteColor(item->bg_color))); } } else if (isSelected(index)) { - painter->AddRectFilled(rect.Min, rect.Max, resize_sig_ ? toImU32(resize_sig_->color) : paletteHighlight()); + painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalHighlight(resize_sig_ ? resize_sig_->color : fromImVec4(palette().header)))); pen = IM_COL32_WHITE; } else if (!hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), resize_sig_) == item->sigs.end()) { // not resizing if (item->sigs.size() > 0) { for (auto &s : item->sigs) { - if (s == hovered_sig_) { - painter->AddRectFilled(rect.Min, rect.Max, toImU32(s->color.darker(125))); // 4/5x brightness - } else { - drawSignalCell(painter, rect, index, s); - } + drawSignalCell(painter, rect, index, s); } } else if (item->valid) { - if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalFill(item->bg_color, false))); } bool bright = std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); - pen = bright ? IM_COL32_WHITE : paletteText(is_message_active_); + if (bright) painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalHighlight(hovered_sig_->color))); + pen = bright ? IM_COL32_WHITE : paletteText(); } + const auto text = fromImVec4(ImGui::ColorConvertU32ToFloat4(pen)); + const ImU32 pattern = toImU32(contrastColor({128, 128, 128}, text)); if (item->sigs.size() > 1) { - fillDense7Pattern(painter, rect, DARK_GRAY); + fillDense7Pattern(painter, rect, pattern); } else if (!item->valid) { - fillBDiagPattern(painter, rect, DARK_GRAY); + fillBDiagPattern(painter, rect, pattern); } if (item->valid) { if (index.column == HEX_COLUMN) { @@ -545,13 +542,13 @@ void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const B auto item = &cellAt(index); CabanaColor color = sig->color; color.a = item->bg_color.alpha(); - const ImU32 edge = toImU32(sig->color.darker(125)); + const ImU32 edge = toImU32(signalOutline(sig->color)); for (const ImRect &clip : region) { painter->PushClipRect(clip.Min, clip.Max, true); // mix the signal color with the background to fade it painter->AddRectFilled(rc.Min, rc.Max, paletteBase()); - painter->AddRectFilled(rc.Min, rc.Max, toImU32(color)); + painter->AddRectFilled(rc.Min, rc.Max, toImU32(signalFill(color))); if (draw_left) painter->AddLine(ImVec2(rc.Min.x + 0.5f, rc.Min.y), ImVec2(rc.Min.x + 0.5f, rc.Max.y), edge, 1.0f); if (draw_right) painter->AddLine(ImVec2(rc.Max.x - 0.5f, rc.Min.y), ImVec2(rc.Max.x - 0.5f, rc.Max.y), edge, 1.0f); diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index b73464c6e2..67169e5561 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -397,7 +397,7 @@ void CenterWidget::drawWelcomeWidget() { ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - size.x) * 0.5f, y)); ImGui::TextUnformatted(text); }; - ImGui::PushStyleColor(ImGuiCol_Text, colorRgb(169, 169, 169)); + ImGui::PushStyleColor(ImGuiCol_Text, palette().text_disabled); float y = origin.y + avail.y * 0.5f - 90.0f; pushLargeFont(); centered("CABANA", y); diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.cc b/openpilot/tools/cabana/ui/widgets/messagebytes.cc index 442f5e598f..fb66726b70 100644 --- a/openpilot/tools/cabana/ui/widgets/messagebytes.cc +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.cc @@ -26,9 +26,9 @@ ImVec2 bytesCellSize(int n, bool multiple_lines) { ImU32 cellTextColor(bool selected, bool inactive) { if (selected) { const ImU32 text = ImGui::GetColorU32(palette().text_selected); - return inactive ? withAlpha(text, 100) : text; + return text; } - return ImGui::GetColorU32(inactive ? ImGuiCol_TextDisabled : ImGuiCol_Text); + return ImGui::GetColorU32(inactive ? palette().text_inactive : palette().text); } void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive, bool align_right) { @@ -48,14 +48,11 @@ void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vectorsize() && (*colors)[i].alpha() > 0) { - if (selected) { - pen = ImGui::GetColorU32(ImGuiCol_Text); - dl->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); - } - dl->AddRectFilled(r.Min, r.Max, toImU32((*colors)[i])); + // Activity backgrounds use the theme's text, including stale values. + pen = ImGui::GetColorU32(ImGuiCol_Text); + dl->AddRectFilled(r.Min, r.Max, toImU32(byteColor((*colors)[i]))); } drawText(dl, r, utils::hexByte(bytes[i]), pen, font, font_size); } diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index 921d4b26ab..6562903935 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -267,7 +267,7 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const if (item->type == SignalModel::Item::Sig) { // color label ImRect icon_rect(rect.Min.x, rect.Min.y, rect.Min.x + COLOR_LABEL_WIDTH, rect.Max.y); - painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(item->sig->color.darker(item->highlight ? 125 : 0)), ImGui::GetStyle().FrameRounding); + painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(item->highlight ? signalHighlight(item->sig->color) : contrastColor(item->sig->color, {0, 0, 0})), ImGui::GetStyle().FrameRounding); drawText(painter, icon_rect, std::to_string(item->row() + 1).c_str(), item->highlight ? IM_COL32_WHITE : IM_COL32_BLACK, nullptr, LABEL_FONT); @@ -276,7 +276,7 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const if (item->sig->type != cabana::Signal::Type::Normal) { const std::string indicator = multiplexIndicator(item->sig); ImRect indicator_rect(rect.Min.x, rect.Min.y, rect.Min.x + ImGui::CalcTextSize(indicator.c_str()).x, rect.Max.y); - painter->AddRectFilled(indicator_rect.Min, indicator_rect.Max, IM_COL32(160, 160, 164, 255), ImGui::GetStyle().FrameRounding); + painter->AddRectFilled(indicator_rect.Min, indicator_rect.Max, ImGui::GetColorU32(palette().badge), ImGui::GetStyle().FrameRounding); drawElidedText(painter, indicator_rect, indicator, IM_COL32_WHITE, false); rect.Min.x = indicator_rect.Max.x + h_margin * 2; } diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 5c8a9ede3c..37b4348fd3 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -425,7 +425,7 @@ void Slider::paint(double thumbnail_time) { groove_rect.Min.y = std::floor(center_y - groove_height / 2); groove_rect.Max.y = groove_rect.Min.y + groove_height; - p->AddRectFilled(groove_rect.Min, groove_rect.Max, timeline_colors[(int)TimelineType::None], groove_height * 0.5f); + p->AddRectFilled(groove_rect.Min, groove_rect.Max, toImU32(graphicColor(fromImVec4(ImGui::ColorConvertU32ToFloat4(timeline_colors[(int)TimelineType::None])), palette().window)), groove_height * 0.5f); double min = minimum() / factor; double max = maximum() / factor; @@ -444,7 +444,7 @@ void Slider::paint(double thumbnail_time) { if (auto replay = getReplay()) { for (const auto &entry : *replay->getTimeline()) { - fillRange(entry.start_time, entry.end_time, timeline_colors[(int)entry.type]); + fillRange(entry.start_time, entry.end_time, toImU32(graphicColor(fromImVec4(ImGui::ColorConvertU32ToFloat4(timeline_colors[(int)entry.type])), palette().window))); } ImU32 empty_color = ImGui::GetColorU32(ImGuiCol_WindowBg, 160 / 255.0f); @@ -531,8 +531,9 @@ void StreamCameraView::draw(const ImVec2 &size, double thumbnail_time) { const char *text = "PAUSED"; const ImVec2 text_size = font->CalcTextSizeA(POINT_16_FONT_SIZE, FLT_MAX, 0.0f, text); const ImVec2 center = rect().GetCenter(); - p->AddText(font, POINT_16_FONT_SIZE, ImVec2(center.x - text_size.x / 2, center.y - text_size.y / 2), - IM_COL32(200, 200, 200, static_cast(255 * 0.7f)), text); + const ImVec2 pos(center.x - text_size.x / 2, center.y - text_size.y / 2); + p->AddRectFilled(ImVec2(pos.x - 4, pos.y - 2), ImVec2(pos.x + text_size.x + 4, pos.y + text_size.y + 2), ImGui::GetColorU32(palette().badge), ImGui::GetStyle().FrameRounding); + p->AddText(font, POINT_16_FONT_SIZE, pos, ImGui::GetColorU32(palette().text_selected), text); } } @@ -583,13 +584,16 @@ void StreamCameraView::drawTime(ImDrawList *p, const ImRect &rect, double second ImFont *font = ImGui::GetFont(); const ImVec2 text_size = font->CalcTextSizeA(POINT_10_FONT_SIZE, FLT_MAX, 0.0f, text); // centered horizontally, above the bottom margin - p->AddText(font, POINT_10_FONT_SIZE, ImVec2(rect.GetCenter().x - text_size.x / 2, rect.Max.y - THUMBNAIL_MARGIN - text_size.y), - IM_COL32_WHITE, text); + const ImVec2 pos(rect.GetCenter().x - text_size.x / 2, rect.Max.y - THUMBNAIL_MARGIN - text_size.y); + p->AddRectFilled(ImVec2(pos.x - 4, pos.y - 2), ImVec2(pos.x + text_size.x + 4, pos.y + text_size.y + 2), ImGui::GetColorU32(palette().badge), ImGui::GetStyle().FrameRounding); + p->AddText(font, POINT_10_FONT_SIZE, pos, ImGui::GetColorU32(palette().text_selected), text); } void StreamCameraView::drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size, float rounding) { const ImU32 pen = IM_COL32_WHITE; - ImU32 color = withAlpha(timeline_colors[int(alert.type)], 128); + // Opaque backing keeps alert text readable over every camera frame. + const auto source = fromImVec4(ImGui::ColorConvertU32ToFloat4(timeline_colors[int(alert.type)])); + const ImU32 color = toImU32(contrastColor(source, fromImVec4(palette().text_selected))); std::string text = alert.text1; if (!alert.text2.empty()) text += "\n" + alert.text2; From d375e7ff5da7c1c3e22eac8b768186b444008b08 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:58:18 -0700 Subject: [PATCH 152/270] cabana: brighten quiet signals in dark mode (#38969) --- openpilot/tools/cabana/ui/widgets/binaryview.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index 9d983b918d..d1ce34ceda 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -541,7 +541,7 @@ void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const B auto item = &cellAt(index); CabanaColor color = sig->color; - color.a = item->bg_color.alpha(); + color.a = std::max(item->bg_color.alpha(), palette().text.x > 0.5f ? 96 : 25); const ImU32 edge = toImU32(signalOutline(sig->color)); for (const ImRect &clip : region) { From 680aa6f8681ebc2ff0bd834c62381e5ab0242218 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:02:54 -0700 Subject: [PATCH 153/270] cabana: revert quiet signal brightness clamp (#38970) Revert "cabana: brighten quiet signals in dark mode (#38969)" This reverts commit d375e7ff5da7c1c3e22eac8b768186b444008b08. --- openpilot/tools/cabana/ui/widgets/binaryview.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index d1ce34ceda..9d983b918d 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -541,7 +541,7 @@ void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const B auto item = &cellAt(index); CabanaColor color = sig->color; - color.a = std::max(item->bg_color.alpha(), palette().text.x > 0.5f ? 96 : 25); + color.a = item->bg_color.alpha(); const ImU32 edge = toImU32(signalOutline(sig->color)); for (const ImRect &clip : region) { From ea0afb67a8a82f4661d28f3d5642335912c01022 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:24:14 -0700 Subject: [PATCH 154/270] cabana: preserve activity with a brighter baseline (#38971) * cabana: preserve activity above a brighter baseline * cabana: apply signal baseline in both themes --- openpilot/tools/cabana/ui/widgets/binaryview.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index 9d983b918d..eb8b77b43c 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -541,7 +541,7 @@ void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const B auto item = &cellAt(index); CabanaColor color = sig->color; - color.a = item->bg_color.alpha(); + color.a = 96 + item->bg_color.alpha() * (255 - 96) / 255; const ImU32 edge = toImU32(signalOutline(sig->color)); for (const ImRect &clip : region) { From 95ed0213eca247730fe6c6080d4c82ced74a8db9 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:00:59 -0700 Subject: [PATCH 155/270] cabana: refine signal heatmap grid (#38964) * cabana: refine signal heatmap grid * cabana: restore direct signal color blending * cabana: strengthen heatmap signal saturation * Revert "cabana: strengthen heatmap signal saturation" This reverts commit 07ee9664d533f260b5bbfaed1afc4391c795603c. * cabana: remove signal cell gradients --- openpilot/tools/cabana/ui/theme.cc | 4 +- .../tools/cabana/ui/widgets/binaryview.cc | 152 +++++++++--------- .../tools/cabana/ui/widgets/binaryview.h | 1 + 3 files changed, 76 insertions(+), 81 deletions(-) diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc index 20cf2919ff..eda301a566 100644 --- a/openpilot/tools/cabana/ui/theme.cc +++ b/openpilot/tools/cabana/ui/theme.cc @@ -208,8 +208,10 @@ CabanaColor byteColor(const CabanaColor &color) { CabanaColor signalFill(const CabanaColor &color, bool defined) { const bool dark = palette().text.x > 0.5f; + // Constrain the full-strength color before blending to preserve the activity ramp. + if (defined) return composite(boundLuminance(color, 0.18, !dark), fromImVec4(palette().surface)); auto fill = composite(color, fromImVec4(palette().surface)); - fill = composite(dark ? CabanaColor(0, 0, 0, 115) : CabanaColor(255, 255, 255, defined ? 20 : 40), fill); + fill = composite(dark ? CabanaColor(0, 0, 0, 115) : CabanaColor(255, 255, 255, 40), fill); return boundLuminance(fill, 0.18, !dark); // 4.5:1 with the theme's text } diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index eb8b77b43c..56f67075f4 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -19,7 +19,7 @@ namespace { const int CELL_HEIGHT = 36; const float CELL_FONT_SIZE = UI_FONT_SIZE + 2.0f; const float SMALL_FONT_SIZE = 10.0f; // Inter needs 10 px for a 7 px cap height -const int VERTICAL_HEADER_WIDTH = 30; +const int GRID_COLUMN_COUNT = BinaryView::COLUMN_COUNT + 1; inline int get_bit_pos(const BinaryIndex &index) { return flipBitPos(index.row * 8 + index.column); } inline ImU32 paletteBase() { return ImGui::GetColorU32(ImGuiCol_ChildBg); } @@ -126,7 +126,7 @@ ImVec2 BinaryView::minimumSizeHint() const { pushMonoFont(CELL_FONT_SIZE); const float min_section_size = std::ceil(ImGui::CalcTextSize("FF").x) + 10.0f; popMonoFont(); - return {(min_section_size + 1) * 9 + VERTICAL_HEADER_WIDTH + 2, + return {min_section_size * GRID_COLUMN_COUNT + 2, static_cast(CELL_HEIGHT * std::min(row_count_, 10) + 2)}; } @@ -173,6 +173,8 @@ void BinaryView::highlightPosition(const ImVec2 &pos) { auto item = &cellAt(index); const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back(); highlight(sig); + } else { + highlight(nullptr); } } @@ -292,18 +294,18 @@ std::tuple BinaryView::getSelection(BinaryIndex index) { } BinaryIndex BinaryView::indexAt(const ImVec2 &pos) const { - if (column_width_ <= 0 || pos.x < grid_pos_.x + VERTICAL_HEADER_WIDTH || pos.y < grid_pos_.y) return {}; - int column = static_cast((pos.x - grid_pos_.x - VERTICAL_HEADER_WIDTH) / column_width_); + if (column_width_ <= 0 || pos.x < grid_pos_.x + IM_ROUND(column_width_) || pos.y < grid_pos_.y) return {}; + int column = 0; + while (column < COLUMN_COUNT && pos.x >= grid_pos_.x + IM_ROUND((column + 2) * column_width_)) ++column; int row = static_cast((pos.y - grid_pos_.y) / CELL_HEIGHT); if (column >= COLUMN_COUNT || row >= row_count_) return {}; return {row, column}; } ImRect BinaryView::visualRect(const BinaryIndex &index) const { - // sections are integral: round the edges so neighboring cells share them exactly and the cells are - // painted edge to edge with no grid line between them - const float x0 = grid_pos_.x + VERTICAL_HEADER_WIDTH + IM_ROUND(index.column * column_width_); - const float x1 = grid_pos_.x + VERTICAL_HEADER_WIDTH + IM_ROUND((index.column + 1) * column_width_); + // Round shared edges consistently across the byte index, bits, and hex column. + const float x0 = grid_pos_.x + IM_ROUND((index.column + 1) * column_width_); + const float x1 = grid_pos_.x + IM_ROUND((index.column + 2) * column_width_); const float y = grid_pos_.y + index.row * CELL_HEIGHT; return ImRect(x0, y, x1, y + CELL_HEIGHT); } @@ -320,17 +322,16 @@ void BinaryView::draw() { pushMonoFont(CELL_FONT_SIZE); const float min_column_width = std::ceil(ImGui::CalcTextSize("FF").x) + 10.0f; popMonoFont(); - const float width = std::max(ImGui::GetContentRegionAvail().x, VERTICAL_HEADER_WIDTH + min_column_width * COLUMN_COUNT); - column_width_ = std::max(min_column_width, (width - VERTICAL_HEADER_WIDTH) / COLUMN_COUNT); + const float width = std::max(ImGui::GetContentRegionAvail().x, min_column_width * GRID_COLUMN_COUNT); + column_width_ = std::max(min_column_width, width / GRID_COLUMN_COUNT); grid_pos_ = ImGui::GetCursorScreenPos(); ImGui::InvisibleButton("##binary_view", ImVec2(std::max(width, 1.0f), std::max(static_cast(rows * CELL_HEIGHT), 1.0f))); ImDrawList *painter = ImGui::GetWindowDrawList(); painter->AddRectFilled(grid_pos_, ImVec2(grid_pos_.x + width, grid_pos_.y + rows * CELL_HEIGHT), paletteBase()); for (int row = 0; row < rows; ++row) { - const ImRect r(grid_pos_.x, grid_pos_.y + row * CELL_HEIGHT, grid_pos_.x + VERTICAL_HEADER_WIDTH, grid_pos_.y + (row + 1) * CELL_HEIGHT); - painter->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); // plain header background - drawText(painter, r, std::to_string(row).c_str(), paletteText()); + const ImRect r(grid_pos_.x, grid_pos_.y + row * CELL_HEIGHT, grid_pos_.x + IM_ROUND(column_width_), grid_pos_.y + (row + 1) * CELL_HEIGHT); + drawText(painter, r, std::to_string(row).c_str(), paletteText(), nullptr, CELL_FONT_SIZE); } for (int row = 0; row < rows; ++row) { for (int column = 0; column < COLUMN_COUNT; ++column) { @@ -339,6 +340,29 @@ void BinaryView::draw() { } } + // One consistent grid over every cell; hover never changes its geometry. + const ImU32 grid_color = ImGui::GetColorU32(ImGuiCol_Border, 0.35f); + const float right = grid_pos_.x + IM_ROUND(GRID_COLUMN_COUNT * column_width_); + const float bottom = grid_pos_.y + rows * CELL_HEIGHT; + for (int column = 0; column <= GRID_COLUMN_COUNT; ++column) { + const float x = std::clamp(grid_pos_.x + IM_ROUND(column * column_width_), grid_pos_.x + 0.5f, right - 0.5f); + painter->AddLine(ImVec2(x, grid_pos_.y), ImVec2(x, bottom), grid_color); + } + for (int row = 0; row <= rows; ++row) { + const float y = grid_pos_.y + row * CELL_HEIGHT; + painter->AddLine(ImVec2(grid_pos_.x, y), ImVec2(right, y), grid_color); + } + + // Signal definitions are a separate layer from heatmap activity. + for (int row = 0; row < rows; ++row) { + for (int column = 0; column < HEX_COLUMN; ++column) { + const BinaryIndex index{row, column}; + for (const auto *sig : cellAt(index).sigs) { + if (!hasSelection() || sig != resize_sig_) drawSignalOutline(painter, visualRect(index), index, sig); + } + } + } + const ImVec2 mouse = ImGui::GetMousePos(); const bool hovered = ImGui::IsItemHovered(); const bool active = ImGui::IsItemActive(); @@ -443,19 +467,12 @@ const std::vector> &BinaryView::bitFlipChanges(size_t ms return bit_flip_tracker_.counts.bits; } -bool BinaryView::hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const { - if (!index.isValid()) return false; - int idx = (index.row + dy) * COLUMN_COUNT + index.column + dx; - if (idx < 0 || idx >= (int)cells_.size()) return false; - auto &s = cells_[idx].sigs; - return std::find(s.begin(), s.end(), sig) != s.end(); -} - void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index) const { auto item = &cellAt(index); ImFont *font = ImGui::GetFont(); float font_size = CELL_FONT_SIZE; ImU32 pen = paletteText(); + const bool hovered = hovered_sig_ && std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); if (index.column == HEX_COLUMN) { if (item->valid) { @@ -473,12 +490,12 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary for (auto &s : item->sigs) { drawSignalCell(painter, rect, index, s); } + // Hover covers the entire signal, including bits shared with another definition. + if (hovered) painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalHighlight(hovered_sig_->color))); } else if (item->valid) { if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalFill(item->bg_color, false))); } - bool bright = std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); - if (bright) painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalHighlight(hovered_sig_->color))); - pen = bright ? IM_COL32_WHITE : paletteText(); + pen = hovered ? IM_COL32_WHITE : paletteText(); } const auto text = fromImVec4(ImGui::ColorConvertU32ToFloat4(pen)); @@ -488,6 +505,7 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary } else if (!item->valid) { fillBDiagPattern(painter, rect, pattern); } + if (item->valid) { if (index.column == HEX_COLUMN) { drawBoldText(painter, rect, utils::hexByte(item->val), pen, font, font_size); @@ -501,64 +519,38 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary } } -// Draw border on edge of signal -void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const { - bool draw_left = !hasSignal(index, -1, 0, sig); - bool draw_top = !hasSignal(index, 0, -1, sig); - bool draw_right = !hasSignal(index, 1, 0, sig); - bool draw_bottom = !hasSignal(index, 0, 1, sig); +bool BinaryView::hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const { + const int row = index.row + dy; + const int column = index.column + dx; + if (row < 0 || row >= row_count_ || column < 0 || column >= HEX_COLUMN) return false; + const auto &signals = cellAt({row, column}).sigs; + return std::find(signals.begin(), signals.end(), sig) != signals.end(); +} - const int spacing = 2; - ImRect rc(rect.Min.x + draw_left * 3, rect.Min.y + draw_top * spacing, rect.Max.x - draw_right * 3, rect.Max.y - draw_bottom * spacing); - std::vector subtract; - if (!draw_top) { - if (!draw_left && !hasSignal(index, -1, -1, sig)) { - subtract.emplace_back(rc.Min.x, rc.Min.y, rc.Min.x + 3, rc.Min.y + spacing); - } else if (!draw_right && !hasSignal(index, 1, -1, sig)) { - subtract.emplace_back(rc.Max.x - 3, rc.Min.y, rc.Max.x, rc.Min.y + spacing); - } - } - if (!draw_bottom) { - if (!draw_left && !hasSignal(index, -1, 1, sig)) { - subtract.emplace_back(rc.Min.x, rc.Max.y - spacing, rc.Min.x + 3, rc.Max.y); - } else if (!draw_right && !hasSignal(index, 1, 1, sig)) { - subtract.emplace_back(rc.Max.x - 3, rc.Max.y - spacing, rc.Max.x, rc.Max.y); - } - } - // rc split into horizontal bands with the notch corners removed: at most one notch in the top band and - // one in the bottom band - const ImRect *top_notch = !subtract.empty() && subtract.front().Min.y == rc.Min.y ? &subtract.front() : nullptr; - const ImRect *bottom_notch = !subtract.empty() && subtract.back().Min.y != rc.Min.y ? &subtract.back() : nullptr; - std::vector region; - auto band = [&](const ImRect *notch, float y0, float y1) { - const float x0 = notch && notch->Min.x == rc.Min.x ? notch->Max.x : rc.Min.x; - const float x1 = notch && notch->Min.x != rc.Min.x ? notch->Min.x : rc.Max.x; - if (x1 > x0 && y1 > y0) region.emplace_back(x0, y0, x1, y1); +void BinaryView::drawSignalOutline(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, + const cabana::Signal *sig) const { + // Definition boundaries remain visible even at zero activity. Keep them above the + // fills and grid, at the same fixed position before and during hover. + const ImU32 edge = toImU32(signalOutline(sig->color, sig == hovered_sig_)); + const bool left = !hasSignal(index, -1, 0, sig); + const bool right = !hasSignal(index, 1, 0, sig); + const bool top = !hasSignal(index, 0, -1, sig); + const bool bottom = !hasSignal(index, 0, 1, sig); + // Extend one pixel into adjacent cells to join inward corners. + auto offset = [&](bool boundary, int dx, int dy) { + return boundary ? 1.0f : hasSignal(index, dx, dy, sig) ? -1.0f : 0.0f; }; - if (top_notch) band(top_notch, rc.Min.y, rc.Min.y + spacing); - band(nullptr, rc.Min.y + (top_notch ? spacing : 0), rc.Max.y - (bottom_notch ? spacing : 0)); - if (bottom_notch) band(bottom_notch, rc.Max.y - spacing, rc.Max.y); + const ImVec2 min = rect.Min, max = rect.Max; + if (left) painter->AddLine(ImVec2(min.x + 1, min.y + offset(top, -1, -1)), ImVec2(min.x + 1, max.y - offset(bottom, -1, 1)), edge); + if (right) painter->AddLine(ImVec2(max.x - 1, min.y + offset(top, 1, -1)), ImVec2(max.x - 1, max.y - offset(bottom, 1, 1)), edge); + if (top) painter->AddLine(ImVec2(min.x + offset(left, -1, -1), min.y + 1), ImVec2(max.x - offset(right, 1, -1), min.y + 1), edge); + if (bottom) painter->AddLine(ImVec2(min.x + offset(left, -1, 1), max.y - 1), ImVec2(max.x - offset(right, 1, 1), max.y - 1), edge); +} - auto item = &cellAt(index); +// Signal intensity and hover share the same full-cell geometry. +void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const { CabanaColor color = sig->color; - color.a = 96 + item->bg_color.alpha() * (255 - 96) / 255; - const ImU32 edge = toImU32(signalOutline(sig->color)); - - for (const ImRect &clip : region) { - painter->PushClipRect(clip.Min, clip.Max, true); - // mix the signal color with the background to fade it - painter->AddRectFilled(rc.Min, rc.Max, paletteBase()); - painter->AddRectFilled(rc.Min, rc.Max, toImU32(signalFill(color))); - - if (draw_left) painter->AddLine(ImVec2(rc.Min.x + 0.5f, rc.Min.y), ImVec2(rc.Min.x + 0.5f, rc.Max.y), edge, 1.0f); - if (draw_right) painter->AddLine(ImVec2(rc.Max.x - 0.5f, rc.Min.y), ImVec2(rc.Max.x - 0.5f, rc.Max.y), edge, 1.0f); - if (draw_bottom) painter->AddLine(ImVec2(rc.Min.x, rc.Max.y - 0.5f), ImVec2(rc.Max.x, rc.Max.y - 0.5f), edge, 1.0f); - if (draw_top) painter->AddLine(ImVec2(rc.Min.x, rc.Min.y + 0.5f), ImVec2(rc.Max.x, rc.Min.y + 0.5f), edge, 1.0f); - - // fill gaps inside corners: the 2px stroke is clipped to the region, only the half outside the notch is painted - for (auto &r : subtract) { - painter->AddRect(r.Min, r.Max, edge, 0.0f, 0, 2.0f); - } - painter->PopClipRect(); - } + color.a = 96 + cellAt(index).bg_color.alpha() * (255 - 96) / 255; + painter->AddRectFilled(rect.Min, rect.Max, paletteBase()); + painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalFill(color))); } diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.h b/openpilot/tools/cabana/ui/widgets/binaryview.h index 507d05811c..4f1afa1032 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.h +++ b/openpilot/tools/cabana/ui/widgets/binaryview.h @@ -76,6 +76,7 @@ private: void paintCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index) const; bool hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const; + void drawSignalOutline(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const; void drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const; MessageId msg_id_; From 1b1b60ba93bf70d3a37bc306a02048fc71dd0b2c Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:54:47 -0700 Subject: [PATCH 156/270] cabana: generate dbc files during builds (#38974) --- openpilot/tools/cabana/SConscript | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index 0249a019bb..b1ef7602a5 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -1,14 +1,23 @@ import os +from pathlib import Path import bootstrap_icons import imgui import libusb from openpilot.common.basedir import BASEDIR +from opendbc.dbc.generator.generator import generate_all Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib', 'ffmpeg_libs') opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (env.Dir("../../../opendbc_repo/opendbc/dbc").abspath) +dbc_dir = Path(env.Dir("#opendbc_repo/opendbc/dbc").abspath) +generated_dbcs = env.Command( + [str(dbc_dir / (name + '.dbc')) for name in sorted(generate_all())], + sorted(str(path) for path in (dbc_dir / 'generator').rglob('*') if path.suffix in ('.py', '.dbc')), + 'python3 -m opendbc.dbc.generator.generator', +) + # embed the bootstrap icons SVG into the binary def build_bootstrap_icons_src(target, source, env): data = open(str(source[0]), 'rb').read() @@ -52,6 +61,7 @@ if arch == "Darwin": else: ui_libs += ['GL', 'dl'] cabana_ui = ui_env.Program('cabana', ui_objs, LIBS=ui_libs) +ui_env.Requires(cabana_ui, generated_dbcs) if GetOption('extras'): cabana_core_test_env = env.Clone() @@ -67,7 +77,8 @@ if GetOption('extras'): cabana_core_test_env.Object('tests/cabana_core_routes', 'routes.cc'), cabana_core_test_env.Object('tests/cabana_core_qtstate', 'ui/qtstate.cc'), ] - cabana_core_test_env.Program('tests/test_cabana', cabana_core_test_objects, LIBS=[replay_lib, common]) + cabana_core_test = cabana_core_test_env.Program('tests/test_cabana', cabana_core_test_objects, LIBS=[replay_lib, common]) + cabana_core_test_env.Requires(cabana_core_test, generated_dbcs) output_json_file = 'openpilot/tools/cabana/dbc/car_fingerprint_to_dbc.json' generate_dbc = ui_env.Command('#' + output_json_file, From 3b2a75a4e08e13a35b6995d0295a96559beddb85 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:41:58 -0700 Subject: [PATCH 157/270] cabana: resize heatmap and prevent signal clipping (#38975) * cabana: allow heatmap resizing without clipping * cabana: keep outer grid lines inside the heatmap * cabana: add a visible heatmap resize handle * cabana: use the standard pane divider for resizing --- .../tools/cabana/ui/widgets/binaryview.cc | 4 +-- .../tools/cabana/ui/widgets/detailwidget.cc | 26 ++++++++++++++----- .../tools/cabana/ui/widgets/detailwidget.h | 1 + 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index 56f67075f4..818f8b42a8 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -348,8 +348,8 @@ void BinaryView::draw() { const float x = std::clamp(grid_pos_.x + IM_ROUND(column * column_width_), grid_pos_.x + 0.5f, right - 0.5f); painter->AddLine(ImVec2(x, grid_pos_.y), ImVec2(x, bottom), grid_color); } - for (int row = 0; row <= rows; ++row) { - const float y = grid_pos_.y + row * CELL_HEIGHT; + for (int row = 0; rows > 0 && row <= rows; ++row) { + const float y = std::clamp(grid_pos_.y + row * CELL_HEIGHT, grid_pos_.y + 0.5f, bottom - 0.5f); painter->AddLine(ImVec2(grid_pos_.x, y), ImVec2(right, y), grid_color); } diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 67169e5561..6378d2f8ca 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -233,16 +233,29 @@ void DetailWidget::drawTabWidget() { page_tabs_.draw(); if (tab_widget_index_ == 0) { if (heatmap_visible_) { - // Keep most of a short window available for signal rows. + // Allow the heatmap to grow while reserving room for signal rows. + const float handle_height = style.DockingSeparatorSize; const float avail = ImGui::GetContentRegionAvail().y; - const float max_height = std::max(avail - style.ItemSpacing.y - SignalView::minimumHeight(), 1.0f); - const float height = std::min(binary_view_->minimumSizeHint().y, max_height); - ImGui::BeginChild("binary_view", ImVec2(0, std::max(height, 1.0f)), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); + const float max_height = std::max(avail - handle_height - style.ItemSpacing.y * 2 - SignalView::minimumHeight(), 1.0f); + const float min_height = std::min(ImGui::GetFrameHeight(), max_height); + float height = std::clamp(heatmap_height_ < 0 ? binary_view_->minimumSizeHint().y : heatmap_height_, min_height, max_height); + ImGui::BeginChild("binary_view", ImVec2(0, height), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); binary_view_->draw(); ImGui::EndChild(); + + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const ImVec2 size(std::max(ImGui::GetContentRegionAvail().x, 1.0f), handle_height); + const ImRect splitter(pos, ImVec2(pos.x + size.x, pos.y + size.y)); + float signal_height = std::max(avail - height - handle_height - style.ItemSpacing.y * 2, SignalView::minimumHeight()); + if (ImGui::SplitterBehavior(splitter, ImGui::GetID("##heatmap_resize"), ImGuiAxis_Y, + &height, &signal_height, min_height, SignalView::minimumHeight(), 4.0f)) { + heatmap_height_ = height; + } + ImGui::Dummy(size); } - ImGui::BeginChild("signal_view", ImVec2(0, 0), ImGuiChildFlags_None, + const float signal_height = std::max(ImGui::GetContentRegionAvail().y, SignalView::minimumHeight()); + ImGui::BeginChild("signal_view", ImVec2(0, signal_height), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); signal_view_rect_ = ImGui::GetCurrentWindow()->Rect(); signal_view_->draw(); @@ -254,8 +267,7 @@ void DetailWidget::drawTabWidget() { void DetailWidget::draw() { tabbar_.draw(); - ImGui::BeginChild("message_content", ImVec2(0, 0), ImGuiChildFlags_AlwaysUseWindowPadding, - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::BeginChild("message_content", ImVec2(0, 0), ImGuiChildFlags_AlwaysUseWindowPadding); drawToolBar(); if (warning_widget_visible_) { diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.h b/openpilot/tools/cabana/ui/widgets/detailwidget.h index 6c64a33bf8..f752488ee1 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.h +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.h @@ -88,6 +88,7 @@ private: bool action_remove_msg_enabled_ = false; bool heatmap_live_ = true; bool heatmap_visible_ = true; + float heatmap_height_ = -1.0f; std::string heatmap_all_text_ = "All"; ImRect binary_view_rect_, signal_view_rect_; // child window rects of the last drawTabWidget std::unique_ptr history_log_; From 521db4c825d37eb5f29acf955daa88da003e4433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Sun, 20 Sep 2026 18:49:17 -0700 Subject: [PATCH 158/270] locationd: reduce initial gyro bias uncertainty (#38981) --- openpilot/selfdrive/locationd/models/pose_kf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/locationd/models/pose_kf.py b/openpilot/selfdrive/locationd/models/pose_kf.py index a8ff80c713..f71503c201 100755 --- a/openpilot/selfdrive/locationd/models/pose_kf.py +++ b/openpilot/selfdrive/locationd/models/pose_kf.py @@ -40,7 +40,7 @@ class PoseKalman(KalmanFilter): initial_P = np.diag([0.01**2, 0.01**2, 0.01**2, 10**2, 10**2, 10**2, 1**2, 1**2, 1**2, - 1**2, 1**2, 1**2, + 0.1**2, 0.1**2, 0.1**2, 100**2, 100**2, 100**2, 0.01**2, 0.01**2, 0.01**2]) From 082c43da392e61a988f8ee6b5f070e140106f2df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Tue, 22 Sep 2026 10:01:32 -0700 Subject: [PATCH 159/270] Chestnut: default GPU power limit to 100W (#38992) modeld: default Chestnut GPU power limit to 100W --- openpilot/selfdrive/modeld/modeld.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index effb511e7d..a6d7b9a1a6 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -5,6 +5,7 @@ import ctypes from functools import cached_property import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom +os.environ.setdefault('AM_POWER_LIMIT', '100') from tinygrad.device import Buffer, Device from tinygrad.dtype import DType, dtypes from tinygrad.engine.realize import lower_and_compile From 66fd3d1bd453312f9f11dc65da423de20f94d297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Tue, 22 Sep 2026 10:02:10 -0700 Subject: [PATCH 160/270] Ship precompiled Chestnut camera warps (#38991) --- .gitattributes | 2 +- .gitignore | 2 +- openpilot/selfdrive/modeld/SConscript | 43 ++++--------------- openpilot/selfdrive/modeld/modeld.py | 1 + .../big_driving_warp_1344x760_tinygrad.pkl | 3 ++ .../big_driving_warp_1928x1208_tinygrad.pkl | 3 ++ tools/release/build_release.sh | 2 + tools/release/release_files.py | 4 +- tools/release/setup_lfs.sh | 2 +- 9 files changed, 22 insertions(+), 40 deletions(-) create mode 100644 openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl create mode 100644 openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl diff --git a/.gitattributes b/.gitattributes index ef658d3055..8a66e81e70 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,7 +3,7 @@ # to move existing files into LFS: # git add --renormalize . *.onnx filter=lfs diff=lfs merge=lfs -text -openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text +openpilot/selfdrive/modeld/models/big_*_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text *.svg filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text *.gif filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index ff67a1cab8..d267608124 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,7 @@ st[0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z] *.stats *.pkl *.pkl* -!openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +!openpilot/selfdrive/modeld/models/big_*_tinygrad.pkl config.json compile_commands.json compare_runtime*.html diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 3d1bae9b44..1cf664661c 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -1,10 +1,8 @@ import glob import os -import time from SCons.Script import Action, Value from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE -from openpilot.selfdrive.modeld.helpers import chestnut_present from openpilot.system.camerad.cameras.nv12_info import get_nv12_info @@ -24,12 +22,6 @@ else: # JIT=2 disables graph batching, which produces incorrect outputs after buffers change. tg_flags = 'DEV=METAL JIT=2' if arch == 'Darwin' else 'DEV=CPU:LLVM' -CHESTNUT = chestnut_present() -if CHESTNUT: - chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32' - # the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it - chestnut_lock = File("models/.chestnut.lock").abspath - # tinygrad calls brew which needs a $HOME in the env mac_brew_string = f'HOME={os.path.expanduser("~")}' if arch == 'Darwin' else '' @@ -38,20 +30,6 @@ compiler = Dir('#tinygrad_repo/examples/openpilot').abspath # build on all cores taskset = 'taskset -c 0-7 ' if arch == 'comma_arm64' else '' -def chestnut_action(command): - def do_compile(target, source, env): - from openpilot.system.hardware.chestnut.flash import link_up - # chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars - for _ in range(10): - if link_up(): - break - time.sleep(1) - else: - print("Chestnut not ready, skipping warp build") - return - return env.Execute(command) - return Action(do_compile, " [CHESTNUT] $TARGET") - def compile_model(onnx_path, pkl_path): onnx_path, target_pkl_path = File(onnx_path).abspath, File(pkl_path).abspath cmd = (f'{tg_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_onnx.py" ' @@ -66,19 +44,14 @@ compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygra compile_model('models/driving_supercombo.onnx', 'models/driving_tinygrad.pkl') model_w, model_h = MEDMODEL_INPUT_SIZE -for chestnut in [False, True] if CHESTNUT else [False]: - file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) - for cam_w, cam_h in camera_configs: - warp_pkl_path = File(f"models/{file_prefix}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath - stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) - cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_warp.py" ' - f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{stride * (y_height + uv_height)} ' - f'--warp-to {model_w}x{model_h} --layout yuv420 --frames 2 ' - f'--output {warp_pkl_path}') - action = chestnut_action(cmd) if chestnut else cmd - node = lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], action) - if chestnut: - lenv.SideEffect(chestnut_lock, node) +for cam_w, cam_h in camera_configs: + warp_pkl_path = File(f"models/driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath + stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) + cmd = (f'{tg_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_warp.py" ' + f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{stride * (y_height + uv_height)} ' + f'--warp-to {model_w}x{model_h} --layout yuv420 --frames 2 ' + f'--output {warp_pkl_path}') + lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], cmd) dm_w, dm_h = DM_INPUT_SIZE for cam_w, cam_h in camera_configs: diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index a6d7b9a1a6..b79362b7a8 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -155,6 +155,7 @@ class ModelState: self.pack_inputs() with open(MODELS_DIR / f'{"big_" if chestnut else ""}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl', 'rb') as f: self.run_warp = pickle.load(f)['run'] + self.run_warp.captured._linear = lower_and_compile(self.run_warp.captured._linear) self.run_model = jits['run'] self.run_model.captured._linear = lower_and_compile(self.run_model.captured._linear) self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in jits['output_specs'].items()} diff --git a/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl new file mode 100644 index 0000000000..fdcaba2f00 --- /dev/null +++ b/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6334d6b92a2cc095bfb1430a252aa2ede1902605d0f9ba89224aa5a87c7dc7c0 +size 863472 diff --git a/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl new file mode 100644 index 0000000000..91c4096993 --- /dev/null +++ b/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bda11a9c2e87636db364862ca68ebe4ff9d7baeeb39c0c7e2010b702ed543283 +size 863216 diff --git a/tools/release/build_release.sh b/tools/release/build_release.sh index 5b3b2a3a8a..686c3ab8d7 100755 --- a/tools/release/build_release.sh +++ b/tools/release/build_release.sh @@ -61,6 +61,8 @@ fi if [ -n "$INCLUDE_BIG_MODEL" ]; then test -f openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl + test -f openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl + test -f openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl fi # Ensure no submodules in release diff --git a/tools/release/release_files.py b/tools/release/release_files.py index caa34d0f39..97145776b6 100755 --- a/tools/release/release_files.py +++ b/tools/release/release_files.py @@ -14,7 +14,7 @@ blacklist = [ "matlab.*.md", - # release scripts configure LFS for the big driving model + # release scripts configure LFS for the big driving model and warps ".lfsconfig", ".gitattributes", ".git$", @@ -31,7 +31,7 @@ if __name__ == "__main__": continue rf = os.fsdecode(tracked_file) - if not os.getenv("INCLUDE_BIG_MODEL") and rf == "openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl": + if not os.getenv("INCLUDE_BIG_MODEL") and rf.startswith("openpilot/selfdrive/modeld/models/big_"): continue blacklisted = any(re.search(p, rf) for p in blacklist) whitelisted = any(re.search(p, rf) for p in whitelist) diff --git a/tools/release/setup_lfs.sh b/tools/release/setup_lfs.sh index 68464af9d6..e2122a87cb 100644 --- a/tools/release/setup_lfs.sh +++ b/tools/release/setup_lfs.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash if [ -n "$INCLUDE_BIG_MODEL" ]; then - echo 'openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text' > .gitattributes + echo 'openpilot/selfdrive/modeld/models/big_*_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text' > .gitattributes fi # Reset filters and hooks since releases exclude .venv. From ec9241f2967040ce0ed1f5e6137bca6ab2153ac7 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 22 Sep 2026 15:16:09 -0700 Subject: [PATCH 161/270] Bump submodules (#38996) * bump opendbc * bump panda --- opendbc_repo | 2 +- panda | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index a3d3b7c6ca..f975309d21 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit a3d3b7c6ca76ed4c97f606e9b2a0e371e8fa52af +Subproject commit f975309d2144a6f1e3c8e4ad2490c799485e23dd diff --git a/panda b/panda index 5314c84d9e..92eb565169 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit 5314c84d9e8d18aada38bd4fff86b2d42b6eb502 +Subproject commit 92eb565169fd553f4dfaf508c1a3f8dbc14fbfbf From e1862e2e170c0b6618d312aaa482819a1db251eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Tue, 22 Sep 2026 15:21:09 -0700 Subject: [PATCH 162/270] Bump opendbc and panda for Rivian alpha longitudinal (#38995) * Bump opendbc and panda for Rivian alpha longitudinal * Bump opendbc to include Tesla reverts --------- Co-authored-by: Shane Smiskol --- opendbc_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendbc_repo b/opendbc_repo index f975309d21..b6326fb2aa 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit f975309d2144a6f1e3c8e4ad2490c799485e23dd +Subproject commit b6326fb2aa8a8e88adeb2d3f0dec96b0960342e6 From 93eec9007732373cab263069edb49c0b647abb5f Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 22 Sep 2026 15:24:40 -0700 Subject: [PATCH 163/270] Tesla: auto-detect 3-bit steering control type (#38997) bump --- opendbc_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendbc_repo b/opendbc_repo index b6326fb2aa..4134c0d1f5 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit b6326fb2aa8a8e88adeb2d3f0dec96b0960342e6 +Subproject commit 4134c0d1f5e8f695e35ea5fedbe88f6d0c3afb76 From 27fa4fca8e674b51ab3b350896deac0b55d4c41e Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 22 Sep 2026 16:16:21 -0700 Subject: [PATCH 164/270] process replay: new Y route (#38998) * new * PIPE?! * bump --- openpilot/selfdrive/test/process_replay/test_processes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/test/process_replay/test_processes.py b/openpilot/selfdrive/test/process_replay/test_processes.py index 9447ecae19..49767c3090 100755 --- a/openpilot/selfdrive/test/process_replay/test_processes.py +++ b/openpilot/selfdrive/test/process_replay/test_processes.py @@ -36,7 +36,7 @@ source_segments = [ ("MAZDA", "bd6a637565e91581|2021-10-30--15-14-53--4"), # MAZDA.MAZDA_CX9_2021 ("FORD", "54827bf84c38b14f|2023-01-26--21-59-07--4"), # FORD.FORD_BRONCO_SPORT_MK1 ("RIVIAN", "bc095dc92e101734|000000db--ee9fe46e57--1"), # RIVIAN.RIVIAN_R1_GEN1 - ("TESLA", "2c912ca5de3b1ee9|0000025d--6eb6bcbca4--4"), # TESLA.TESLA_MODEL_Y + ("TESLA", "aebd8f1d4ea16066|000002d8--5850d3ea5c--2"), # TESLA.TESLA_MODEL_Y # Enable when port is tested and dashcamOnly is no longer set #("VOLKSWAGEN2", "3cfdec54aa035f3f|2022-07-19--23-45-10--2"), # VOLKSWAGEN.VOLKSWAGEN_PASSAT_NMS @@ -60,7 +60,7 @@ segments = [ ("MAZDA", "regenACF84CCF482|2024-08-30--03-21-55--0"), ("FORD", "regen755D8CB1E1F|2025-04-08--23-13-43--0"), ("RIVIAN", "regen5FCAC896BBE|2025-04-08--23-13-35--0"), - ("TESLA", "2c912ca5de3b1ee9|0000025d--6eb6bcbca4--4"), + ("TESLA", "aebd8f1d4ea16066|000002d8--5850d3ea5c--2"), ] # dashcamOnly makes don't need to be tested until a full port is done From 841076f6d18a6fb951783ed168bed1ddb0685011 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 22 Sep 2026 16:21:36 -0700 Subject: [PATCH 165/270] Revert "process replay: new Y route" (#38999) Revert "process replay: new Y route (#38998)" This reverts commit 27fa4fca8e674b51ab3b350896deac0b55d4c41e. --- openpilot/selfdrive/test/process_replay/test_processes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/test/process_replay/test_processes.py b/openpilot/selfdrive/test/process_replay/test_processes.py index 49767c3090..9447ecae19 100755 --- a/openpilot/selfdrive/test/process_replay/test_processes.py +++ b/openpilot/selfdrive/test/process_replay/test_processes.py @@ -36,7 +36,7 @@ source_segments = [ ("MAZDA", "bd6a637565e91581|2021-10-30--15-14-53--4"), # MAZDA.MAZDA_CX9_2021 ("FORD", "54827bf84c38b14f|2023-01-26--21-59-07--4"), # FORD.FORD_BRONCO_SPORT_MK1 ("RIVIAN", "bc095dc92e101734|000000db--ee9fe46e57--1"), # RIVIAN.RIVIAN_R1_GEN1 - ("TESLA", "aebd8f1d4ea16066|000002d8--5850d3ea5c--2"), # TESLA.TESLA_MODEL_Y + ("TESLA", "2c912ca5de3b1ee9|0000025d--6eb6bcbca4--4"), # TESLA.TESLA_MODEL_Y # Enable when port is tested and dashcamOnly is no longer set #("VOLKSWAGEN2", "3cfdec54aa035f3f|2022-07-19--23-45-10--2"), # VOLKSWAGEN.VOLKSWAGEN_PASSAT_NMS @@ -60,7 +60,7 @@ segments = [ ("MAZDA", "regenACF84CCF482|2024-08-30--03-21-55--0"), ("FORD", "regen755D8CB1E1F|2025-04-08--23-13-43--0"), ("RIVIAN", "regen5FCAC896BBE|2025-04-08--23-13-35--0"), - ("TESLA", "aebd8f1d4ea16066|000002d8--5850d3ea5c--2"), + ("TESLA", "2c912ca5de3b1ee9|0000025d--6eb6bcbca4--4"), ] # dashcamOnly makes don't need to be tested until a full port is done From 8377c40da10606f8f2f59b163250a4cb69ea2354 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 22 Sep 2026 17:25:29 -0700 Subject: [PATCH 166/270] process replay: push new refs (#39004) --- .../selfdrive/test/process_replay/test_processes.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/openpilot/selfdrive/test/process_replay/test_processes.py b/openpilot/selfdrive/test/process_replay/test_processes.py index 9447ecae19..140a4ee9ca 100755 --- a/openpilot/selfdrive/test/process_replay/test_processes.py +++ b/openpilot/selfdrive/test/process_replay/test_processes.py @@ -74,11 +74,19 @@ EXCLUDED_PROCS = {"modeld", "dmonitoringmodeld"} def run_test_process(data): segment, cfg, args, cur_log_fn, ref_log_path, lr_dat = data - ref_log_msgs = list(LogReader(ref_log_path)) + ref_error = None + try: + ref_log_msgs = list(LogReader(ref_log_path)) + except Exception: + # New routes have no ref yet, still replay and publish new refs. + ref_log_msgs = [] + ref_error = f"Failed to load reference {ref_log_path}:\n{traceback.format_exc()}" lr = LogReader.from_bytes(lr_dat) res, log_msgs = test_process(cfg, lr, segment, ref_log_msgs, cur_log_fn, args.ignore_fields, args.ignore_msgs) # save logs so we can update refs save_log(cur_log_fn, log_msgs) + if ref_error is not None: + return (segment, cfg.proc_name, ref_error, ref_error) try: diff_data = diff_process(cfg, ref_log_msgs, log_msgs) except Exception: From a86351c3efcc72a407f8b16049861661a0904ccb Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 22 Sep 2026 17:27:51 -0700 Subject: [PATCH 167/270] process replay: new Y route (#39000) * new * PIPE?! * bump --------- Co-authored-by: Daniel Koepping --- openpilot/selfdrive/test/process_replay/test_processes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/test/process_replay/test_processes.py b/openpilot/selfdrive/test/process_replay/test_processes.py index 140a4ee9ca..320b97296a 100755 --- a/openpilot/selfdrive/test/process_replay/test_processes.py +++ b/openpilot/selfdrive/test/process_replay/test_processes.py @@ -36,7 +36,7 @@ source_segments = [ ("MAZDA", "bd6a637565e91581|2021-10-30--15-14-53--4"), # MAZDA.MAZDA_CX9_2021 ("FORD", "54827bf84c38b14f|2023-01-26--21-59-07--4"), # FORD.FORD_BRONCO_SPORT_MK1 ("RIVIAN", "bc095dc92e101734|000000db--ee9fe46e57--1"), # RIVIAN.RIVIAN_R1_GEN1 - ("TESLA", "2c912ca5de3b1ee9|0000025d--6eb6bcbca4--4"), # TESLA.TESLA_MODEL_Y + ("TESLA", "aebd8f1d4ea16066|000002d8--5850d3ea5c--2"), # TESLA.TESLA_MODEL_Y # Enable when port is tested and dashcamOnly is no longer set #("VOLKSWAGEN2", "3cfdec54aa035f3f|2022-07-19--23-45-10--2"), # VOLKSWAGEN.VOLKSWAGEN_PASSAT_NMS @@ -60,7 +60,7 @@ segments = [ ("MAZDA", "regenACF84CCF482|2024-08-30--03-21-55--0"), ("FORD", "regen755D8CB1E1F|2025-04-08--23-13-43--0"), ("RIVIAN", "regen5FCAC896BBE|2025-04-08--23-13-35--0"), - ("TESLA", "2c912ca5de3b1ee9|0000025d--6eb6bcbca4--4"), + ("TESLA", "aebd8f1d4ea16066|000002d8--5850d3ea5c--2"), ] # dashcamOnly makes don't need to be tested until a full port is done From f00d226d39e3900b6628f158c2c93738d67c9b18 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 22 Sep 2026 19:24:51 -0700 Subject: [PATCH 168/270] add max critical alert sound (#39006) Add max critical alert sound after eight seconds --- .../assets/sounds/dm_critical_max.wav | 3 +++ openpilot/selfdrive/ui/soundd.py | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 openpilot/selfdrive/assets/sounds/dm_critical_max.wav diff --git a/openpilot/selfdrive/assets/sounds/dm_critical_max.wav b/openpilot/selfdrive/assets/sounds/dm_critical_max.wav new file mode 100644 index 0000000000..9526364f99 --- /dev/null +++ b/openpilot/selfdrive/assets/sounds/dm_critical_max.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c4699e3a456a0df2bbfac7ab1eb8972fc8bd970296133fd322e214c61884407 +size 52374 diff --git a/openpilot/selfdrive/ui/soundd.py b/openpilot/selfdrive/ui/soundd.py index 55bf9c8466..aa0f54c5ad 100644 --- a/openpilot/selfdrive/ui/soundd.py +++ b/openpilot/selfdrive/ui/soundd.py @@ -18,7 +18,8 @@ SAMPLE_RATE = 48000 SAMPLE_BUFFER = 4096 # (approx 100ms) MAX_VOLUME = 1.0 MIN_VOLUME = 0.1 -ALERT_RAMP_TIME = 4 # seconds to ramp to max volume for warningImmediate +ALERT_RAMP_TIME = 4 # seconds to ramp critical alerts to max volume +ALERT_MAX_TIME = 8 # seconds before critical alerts switch to the max sound SELFDRIVE_STATE_TIMEOUT = 5 # 5 seconds FILTER_DT = 1. / (micd.SAMPLE_RATE / micd.FFT_SAMPLES) @@ -31,6 +32,7 @@ if HARDWARE.get_device_type() == "tizi": VOLUME_BASE = 10 AudibleAlert = log.SelfdriveState.AudibleAlert +CRITICAL_MAX = -1 # internal sound key, not an AudibleAlert sound_list: dict[int, tuple[str, int | None, float]] = { @@ -47,6 +49,7 @@ sound_list: dict[int, tuple[str, int | None, float]] = { AudibleAlert.warningSoft: ("critical.wav", None, MAX_VOLUME), AudibleAlert.warningImmediate: ("dm_critical.wav", None, MAX_VOLUME), + CRITICAL_MAX: ("dm_critical_max.wav", None, MAX_VOLUME), } def check_selfdrive_timeout_alert(sm): @@ -64,6 +67,7 @@ class Soundd: self.load_sounds() self.current_alert = AudibleAlert.none + self.current_sound = AudibleAlert.none self.current_volume = MIN_VOLUME self.current_sound_frame = 0 @@ -96,7 +100,7 @@ class Soundd: if self.current_alert != AudibleAlert.none: num_loops = sound_list[self.current_alert][1] - sound_data = self.loaded_sounds[self.current_alert] + sound_data = self.loaded_sounds[self.current_sound] written_frames = 0 current_sound_frame = self.current_sound_frame % len(sound_data) @@ -123,7 +127,7 @@ class Soundd: data_out[:frames, 0] = self.get_sound_data(frames) def update_alert(self, new_alert): - current_alert_played_once = self.current_alert == AudibleAlert.none or self.current_sound_frame >= len(self.loaded_sounds[self.current_alert]) + current_alert_played_once = self.current_alert == AudibleAlert.none or self.current_sound_frame >= len(self.loaded_sounds[self.current_sound]) # let looping sounds finish the current loop instead of cutting off mid tone if new_alert == AudibleAlert.none and self.current_alert != AudibleAlert.none and sound_list[self.current_alert][1] is None: if current_alert_played_once: @@ -134,10 +138,11 @@ class Soundd: return self.pending_stop = False if self.current_alert != new_alert and (new_alert != AudibleAlert.none or current_alert_played_once): - if new_alert == AudibleAlert.warningImmediate: + if new_alert in (AudibleAlert.warningSoft, AudibleAlert.warningImmediate): self.ramp_start_volume = self.current_volume self.ramp_start_time = time.monotonic() self.current_alert = new_alert + self.current_sound = new_alert self.current_sound_frame = 0 def get_audible_alert(self, sm): @@ -184,11 +189,13 @@ class Soundd: self.get_audible_alert(sm) - # Ramp up immediate warning sound over 4s - if self.current_alert == AudibleAlert.warningImmediate: + if self.current_alert in (AudibleAlert.warningSoft, AudibleAlert.warningImmediate): elapsed = time.monotonic() - self.ramp_start_time ramp_vol = float(np.interp(elapsed, [0, ALERT_RAMP_TIME], [self.ramp_start_volume, MAX_VOLUME])) self.current_volume = max(self.current_volume, ramp_vol) + if elapsed >= ALERT_MAX_TIME and self.current_sound != CRITICAL_MAX: + self.current_sound = CRITICAL_MAX + self.current_sound_frame = 0 rk.keep_time() From 4dbdd66e0c7120a66a258bf91d74350d3f64715d Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Wed, 23 Sep 2026 10:43:03 -0700 Subject: [PATCH 169/270] fix AGNOS updater keyboard crash (#39005) * fix AGNOS updater keyboard across raylib versions * ui: handle raylib 5 keyboard argument errors --- openpilot/common/hardware/comma/updater | 4 ++-- openpilot/system/ui/widgets/mici_keyboard.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/openpilot/common/hardware/comma/updater b/openpilot/common/hardware/comma/updater index 35227d2d79..241d0fc168 100755 --- a/openpilot/common/hardware/comma/updater +++ b/openpilot/common/hardware/comma/updater @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6a7adb302d378dda7b1788a841b89e4f905c872550a70a76650dcde977b4ece0 -size 24709209 +oid sha256:f92c0a9b41613e063b6a1cd086729f5555d9df6482249e171ed1e276272cad7d +size 24709520 diff --git a/openpilot/system/ui/widgets/mici_keyboard.py b/openpilot/system/ui/widgets/mici_keyboard.py index bc3041aa3c..97a33e95e7 100644 --- a/openpilot/system/ui/widgets/mici_keyboard.py +++ b/openpilot/system/ui/widgets/mici_keyboard.py @@ -353,8 +353,12 @@ class MiciKeyboard(Widget): # draw black circle behind selected key circle_alpha = int(self._selected_key_filter.x * 225) - rl.draw_circle_gradient(rl.Vector2(key_x + key.rect.width / 2, key_y + key.rect.height / 2), - SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, circle_alpha), rl.BLANK) + try: + rl.draw_circle_gradient(rl.Vector2(key_x + key.rect.width / 2, key_y + key.rect.height / 2), + SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, circle_alpha), rl.BLANK) + except (TypeError, RuntimeError): # AGNOS updater compatibility for raylib 5 + rl.draw_circle_gradient(int(key_x + key.rect.width / 2), int(key_y + key.rect.height / 2), + SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, circle_alpha), rl.BLANK) else: # move other keys away from selected key a bit dx = key.original_position.x - self._closest_key[0].original_position.x From c5543b1a05104f50e2a4d51a44b084648aee94f2 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Wed, 23 Sep 2026 15:13:47 -0700 Subject: [PATCH 170/270] modeld: set realtime scheduling after model load (#39007) modeld: defer realtime scheduling until models are loaded --- openpilot/selfdrive/modeld/modeld.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index b79362b7a8..4801cb549e 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -2,6 +2,7 @@ from collections.abc import Callable import base64 import ctypes +import gc from functools import cached_property import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom @@ -234,7 +235,7 @@ def main(demo=False): params.put_bool("ChestnutLoading", CHESTNUT) params.remove("ChestnutActive") - config_realtime_process(7, 54) + gc.disable() # visionipc clients while True: @@ -284,6 +285,8 @@ def main(demo=False): params.put_bool("ChestnutLoading", False) cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") + config_realtime_process(7, 54) + # messaging pub_socks = ["modelV2", "drivingModelData", "cameraOdometry"] + (["chestnutGpuState"] if CHESTNUT else []) pm = PubMaster(pub_socks) From afa47035ba71a608b26282f4f571af921ab6ce08 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Wed, 23 Sep 2026 16:36:47 -0700 Subject: [PATCH 171/270] modeld: check model.chestnut in fallback (#39008) --- openpilot/selfdrive/modeld/modeld.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 4801cb549e..f33684dc47 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -406,7 +406,7 @@ def main(demo=False): run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutGpuState'].frequency) == 0) model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None) except Exception: - if not params.get_bool("ChestnutActive"): + if not model.chestnut: raise # fallback to small model cloudlog.exception("big model failed, fall back to small") From f2c772ff1182d07856e4e415dda7c8806de67954 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 24 Sep 2026 12:19:05 -0700 Subject: [PATCH 172/270] hardwared: log usb-c connected (#39010) hardwared: log usb cable connected --- openpilot/cereal/log.capnp | 1 + openpilot/common/hardware/usb.py | 5 +++++ openpilot/selfdrive/ui/ui_state.py | 4 ++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index ab53884d53..60461bde61 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -695,6 +695,7 @@ struct PeripheralState { struct UsbState { devices @0 :List(Device); + connected @1 :Bool; struct Device { busnum @0 :UInt8; diff --git a/openpilot/common/hardware/usb.py b/openpilot/common/hardware/usb.py index c3c2050b6b..ba1d4f0cbc 100644 --- a/openpilot/common/hardware/usb.py +++ b/openpilot/common/hardware/usb.py @@ -36,6 +36,10 @@ def read_int(path: Path, base: int = 10) -> int: return 0 +def cable_connected() -> bool: + return read_int(TYPEC_CC_ORIENTATION_PATH) != 0 + + def usb_devices() -> list[Path]: try: devices = (d for d in USB_DEVICES_PATH.glob("*") if (d / "idVendor").exists()) @@ -91,3 +95,4 @@ def set_usb_state(device_state, devices: list[dict]) -> None: chestnut_present = True device_state.chestnutPresent = chestnut_present + device_state.usbState.connected = cable_connected() diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index ae1fad0b66..08f63228b7 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -12,7 +12,7 @@ from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.ui.lib.prime_state import PrimeState from openpilot.system.ui.lib.application import gui_app from openpilot.common.hardware import HARDWARE, PC -from openpilot.common.hardware.usb import TYPEC_CC_ORIENTATION_PATH, get_usb_state, is_chestnut_usb_id, read_int +from openpilot.common.hardware.usb import cable_connected, get_usb_state, is_chestnut_usb_id from openpilot.selfdrive.modeld.helpers import chestnut_compiled BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50 @@ -252,7 +252,7 @@ class UIState: self.chestnut_active = self.params.get("ChestnutActive") self.chestnut_loading = self.params.get_bool("ChestnutLoading") now = time.monotonic() - if read_int(TYPEC_CC_ORIENTATION_PATH) != 0: + if cable_connected(): self.usb_disconnected_ts = None if not self.usb_connected: self.usb_connected = True From bbabd94c11cb160c96a81bfacbd4f4ec97ade9a9 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 24 Sep 2026 13:24:58 -0700 Subject: [PATCH 173/270] modeld: wait for chestnut to enumerate (#39011) * modeld: wait for chestnut to enumerate * trigger ci --- openpilot/selfdrive/modeld/helpers.py | 9 +++++++++ openpilot/selfdrive/modeld/modeld.py | 6 ++++-- openpilot/selfdrive/ui/ui_state.py | 6 +++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 0d88e12a6a..9ac772e697 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -1,4 +1,5 @@ import sys +import time from pathlib import Path from openpilot.common.hardware import AGNOS @@ -29,6 +30,14 @@ def chestnut_present() -> bool: pass return False +def wait_for_chestnut(timeout: float = 10.) -> None: + # the cable is detected before chestnut enumerates + st = time.monotonic() + while not chestnut_present(): + if time.monotonic() - st > timeout: + raise TimeoutError("chestnut did not enumerate") + time.sleep(0.1) + def chestnut_compiled() -> bool: path = modeld_pkl_path(chestnut=True) return path.is_file() and all( diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index f33684dc47..ee76d72f46 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -28,6 +28,7 @@ from msgq.visionipc import VisionIpcClient, VisionBuf from opendbc.car.car_helpers import get_demo_car_params from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params +from openpilot.common.hardware.usb import cable_connected from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.realtime import config_realtime_process, DT_MDL from openpilot.common.transformations.camera import DEVICE_CAMERAS @@ -38,7 +39,7 @@ from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, from openpilot.selfdrive.modeld.parse_model_outputs import Parser from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState from openpilot.selfdrive.modeld.constants import ModelConstants, Plan -from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob +from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob, wait_for_chestnut SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') @@ -228,7 +229,7 @@ class ModelState: def main(demo=False): cloudlog.warning("modeld init") - CHESTNUT = chestnut_present() and chestnut_compiled() + CHESTNUT = chestnut_compiled() and (chestnut_present() or cable_connected()) if CHESTNUT: os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' params = Params() @@ -268,6 +269,7 @@ def main(demo=False): def load_big(): nonlocal big_model try: + wait_for_chestnut() m = ModelState(vipc_client_main.width, vipc_client_main.height, True) m.warmup() big_model = m diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index 08f63228b7..fecafc6b46 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -202,7 +202,7 @@ class UIState: self.status = UIStatus.DISENGAGED self.started_frame = self.sm.frame self.started_time = time.monotonic() - self.chestnut_present = self.sm["deviceState"].chestnutPresent + self.chestnut_present = self.sm["deviceState"].chestnutPresent or (self.chestnut_compiled and self.usb_connected) for callback in self._offroad_transition_callbacks: callback() @@ -222,10 +222,10 @@ class UIState: self.chestnut_state = ChestnutState.DISCONNECTED elif not self.chestnut_compiled: self.chestnut_state = ChestnutState.UNCOMPILED - elif self.chestnut_state == ChestnutState.FAILED or not detected or (model_seen and (not self.sm.alive["modelV2"] or not self.sm["modelV2"].big)): - self.chestnut_state = ChestnutState.FAILED elif self.chestnut_loading or not model_seen: self.chestnut_state = ChestnutState.LOADING + elif self.chestnut_state == ChestnutState.FAILED or not detected or (model_seen and (not self.sm.alive["modelV2"] or not self.sm["modelV2"].big)): + self.chestnut_state = ChestnutState.FAILED elif self.chestnut_active is False: self.chestnut_state = ChestnutState.FAILED else: From 027770d49cbbd136f2573ceaa29925875cc1c291 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 24 Sep 2026 14:59:23 -0700 Subject: [PATCH 174/270] modeld: fix 3s chestnut timeout (#39012) modeld: restore 3s chestnut timeout --- openpilot/selfdrive/modeld/modeld.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index ee76d72f46..0d90f1225e 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -231,7 +231,8 @@ def main(demo=False): CHESTNUT = chestnut_compiled() and (chestnut_present() or cable_connected()) if CHESTNUT: - os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' + from tinygrad.runtime.ops_amd import AMDDevice + AMDDevice.wait_timeout_ms = 3000 params = Params() params.put_bool("ChestnutLoading", CHESTNUT) params.remove("ChestnutActive") From 46b97e61958e56198b3ad31e66d808698075b1bc Mon Sep 17 00:00:00 2001 From: Amy Jeanes Date: Fri, 25 Sep 2026 19:22:22 +0100 Subject: [PATCH 175/270] ui: match tizi wide/narrow cam speed gates to mici (#39016) ui: match the comma 3X road camera speed gates to the comma 4 Experimental Mode switches the road view to the wide camera below 5 m/s (11 mph) and back to the narrow camera above 10 m/s (22 mph) on both devices. Also corrects the comma 4's mph comments (were 10 and 25 mph). --- openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py | 4 ++-- openpilot/selfdrive/ui/onroad/augmented_road_view.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py index ddc04d7560..3430d72f61 100644 --- a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -31,8 +31,8 @@ class BookmarkState(IntEnum): DRAGGING = 1 TRIGGERED = 2 -WIDE_CAM_MAX_SPEED = 5.0 # m/s (10 mph) -ROAD_CAM_MIN_SPEED = 10 # m/s (25 mph) +WIDE_CAM_MAX_SPEED = 5.0 # m/s (11 mph) +ROAD_CAM_MIN_SPEED = 10.0 # m/s (22 mph) CAM_Y_OFFSET = 20 diff --git a/openpilot/selfdrive/ui/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/onroad/augmented_road_view.py index cae462e41d..a16fb3607b 100644 --- a/openpilot/selfdrive/ui/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/onroad/augmented_road_view.py @@ -25,8 +25,8 @@ BORDER_COLORS = { UIStatus.ENGAGED: rl.Color(0x16, 0x7F, 0x40, 0xFF), # Green for engaged state } -WIDE_CAM_MAX_SPEED = 10.0 # m/s (22 mph) -ROAD_CAM_MIN_SPEED = 15.0 # m/s (34 mph) +WIDE_CAM_MAX_SPEED = 5.0 # m/s (11 mph) +ROAD_CAM_MIN_SPEED = 10.0 # m/s (22 mph) INF_POINT = np.array([1000.0, 0.0, 0.0]) From b78331e042e9b627650c054c06cd054d57fc22da Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Fri, 25 Sep 2026 15:29:22 -0700 Subject: [PATCH 176/270] chestnut: offroad alert for model error (#39018) chestnut: offroad alert for big model errors --- openpilot/common/params_keys.h | 1 + openpilot/selfdrive/selfdrived/alerts_offroad.json | 4 ++++ openpilot/system/hardware/chestnut/status.py | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 96bef4daa7..f5e82b1d9d 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -93,6 +93,7 @@ inline static std::unordered_map keys = { {"ObdMultiplexingEnabled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"Offroad_CarUnrecognized", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_ChestnutBranch", {CLEAR_ON_MANAGER_START, JSON}}, + {"Offroad_ChestnutModelError", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_ChestnutNotDetected", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_ChestnutOverheated", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_ChestnutPcieUnavailable", {CLEAR_ON_MANAGER_START, JSON}}, diff --git a/openpilot/selfdrive/selfdrived/alerts_offroad.json b/openpilot/selfdrive/selfdrived/alerts_offroad.json index c5ee4650a3..66eec0b8a8 100644 --- a/openpilot/selfdrive/selfdrived/alerts_offroad.json +++ b/openpilot/selfdrive/selfdrived/alerts_offroad.json @@ -21,6 +21,10 @@ "text": "Chestnut detected! Switch to the release-chestnut branch to use chestnut-class models.", "severity": -1 }, + "Offroad_ChestnutModelError": { + "text": "Chestnut model error. Restart the car to retry.", + "severity": 0 + }, "Offroad_ChestnutNotDetected": { "text": "Chestnut not detected. Check USB and 12V connections.", "severity": 0 diff --git a/openpilot/system/hardware/chestnut/status.py b/openpilot/system/hardware/chestnut/status.py index 8c38987dff..b088c56b22 100644 --- a/openpilot/system/hardware/chestnut/status.py +++ b/openpilot/system/hardware/chestnut/status.py @@ -23,6 +23,7 @@ class ChestnutStatus: self.link_failures = 0 self.model_loading_seen = False self.model_attempted = False + self.model_failed = False self.overheated = False self.usb_seen = False self.usb_failed = False @@ -42,11 +43,13 @@ class ChestnutStatus: self.link_failures = 0 self.model_loading_seen = False self.model_attempted = False + self.model_failed = False self.usb_seen = firmware_ok self.usb_failed = False self.model_loading_seen |= model_loading self.model_attempted |= self.model_loading_seen and not model_loading and model_active is not None + self.model_failed |= not offroad and self.model_attempted and model_active is False if not offroad and self.usb_seen and not firmware_ok: self.usb_failed = True @@ -91,6 +94,7 @@ class ChestnutStatus: else: pcie_alert = "Chestnut GPU unavailable. PCIe link is not up. Check the GPU is securely seated." set_alert("Offroad_ChestnutPcieUnavailable", self.pcie_failed, pcie_alert) + set_alert("Offroad_ChestnutModelError", self.model_failed and not (missing or self.pcie_failed)) set_alert("Offroad_ChestnutUncompiled", offroad and firmware_ok and not chestnut_compiled()) set_alert("Offroad_ChestnutUpdateFailed", offroad and firmware_failed) self.offroad = offroad From f474f059289295280c6f6c96fca3cbadd622f2a0 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Fri, 25 Sep 2026 16:12:28 -0700 Subject: [PATCH 177/270] modeld: fix failed build on PCs without LLVM (#39019) --- openpilot/selfdrive/modeld/SConscript | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 1cf664661c..2959d92e02 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -1,3 +1,4 @@ +import ctypes.util import glob import os from SCons.Script import Action, Value @@ -7,6 +8,13 @@ from openpilot.system.camerad.cameras.nv12_info import get_nv12_info Import('env', 'arch') + +# TODO: add GCC support to tinygrad? +# tinygrad's CPU:LLVM backend requires libLLVM, skip the build on PC if it's not available +if arch not in ('comma_arm64', 'Darwin') and ctypes.util.find_library('LLVM') is None: + print("modeld: LLVM not found, skipping tinygrad model compilation") + Return() + lenv = env.Clone() lenv.PrependENVPath('PYTHONPATH', Dir('#tinygrad_repo').abspath) From 49bbba371c29c5612e69b8ea9f906e151410e8bc Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Fri, 25 Sep 2026 16:15:25 -0700 Subject: [PATCH 178/270] add color to modeld warning print --- openpilot/selfdrive/modeld/SConscript | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 2959d92e02..c8892ef912 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -12,7 +12,7 @@ Import('env', 'arch') # TODO: add GCC support to tinygrad? # tinygrad's CPU:LLVM backend requires libLLVM, skip the build on PC if it's not available if arch not in ('comma_arm64', 'Darwin') and ctypes.util.find_library('LLVM') is None: - print("modeld: LLVM not found, skipping tinygrad model compilation") + print("\033[93mmodeld: LLVM not found, skipping tinygrad model compilation. Install LLVM to compile models.\033[0m") Return() lenv = env.Clone() From 134517ceb39fc98098823d1ba9130ee91573f03d Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Mon, 28 Sep 2026 16:21:29 -0700 Subject: [PATCH 179/270] AGNOS 19.9 (#39026) --- launch_env.sh | 2 +- openpilot/common/hardware/comma/agnos.json | 24 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/launch_env.sh b/launch_env.sh index 0cb06e7e5b..0f913f287a 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -16,7 +16,7 @@ export VECLIB_MAXIMUM_THREADS=1 export QCOM_PRIORITY=12 if [ -z "$AGNOS_VERSION" ]; then - export AGNOS_VERSION="19.8" + export AGNOS_VERSION="19.9" fi export STAGING_ROOT="/data/safe_staging" diff --git a/openpilot/common/hardware/comma/agnos.json b/openpilot/common/hardware/comma/agnos.json index 92c6a4c7cb..96803bdb85 100644 --- a/openpilot/common/hardware/comma/agnos.json +++ b/openpilot/common/hardware/comma/agnos.json @@ -56,29 +56,29 @@ }, { "name": "boot", - "url": "https://commadist.azureedge.net/agnosupdate/boot-335c17579879614a3b6f9b58c867aa2e66609530258e12d9174d6007494fdb11.img.xz", - "hash": "335c17579879614a3b6f9b58c867aa2e66609530258e12d9174d6007494fdb11", - "hash_raw": "335c17579879614a3b6f9b58c867aa2e66609530258e12d9174d6007494fdb11", - "size": 46897152, + "url": "https://commadist.azureedge.net/agnosupdate/boot-b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615.img.xz", + "hash": "b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615", + "hash_raw": "b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615", + "size": 46962688, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "545d64ff38626d0aff49054cdb7abc735992178f4872fd26ac544d652af38544" + "ondevice_hash": "3ef4769bb1cc73bfc31f96f073b46af42dcc0bad4ab2dde32d11929745d0cd28" }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-20d7a522190ba8b6351364beca9702f40303651577d7c3f6c2c1cb6b34e5db6b.img.xz", - "hash": "62c6ecab2772f48b90dd2e7f0c3bb413dd7577d1ae9aad971135ce0d9d141544", - "hash_raw": "20d7a522190ba8b6351364beca9702f40303651577d7c3f6c2c1cb6b34e5db6b", + "url": "https://commadist.azureedge.net/agnosupdate/system-80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7.img.xz", + "hash": "9aebca09b9d142718e71efbfe44b723010eec6e70067c043c63186067291739d", + "hash_raw": "80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7", "size": 4718592000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "14251b982859824ea12f5e5c63b336b6d6333779d42457aff7e43bbf9c9fd79b", + "ondevice_hash": "270b76c5c5568828e3883390b83ea445425a104dd42119d68f06093ce6b6fcef", "alt": { - "hash": "20d7a522190ba8b6351364beca9702f40303651577d7c3f6c2c1cb6b34e5db6b", - "url": "https://commadist.azureedge.net/agnosupdate/system-20d7a522190ba8b6351364beca9702f40303651577d7c3f6c2c1cb6b34e5db6b.img", + "hash": "80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7", + "url": "https://commadist.azureedge.net/agnosupdate/system-80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7.img", "size": 4718592000 } } -] +] \ No newline at end of file From 0c464e5c66418f11e9cdbcea824b10b97dad37dd Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Mon, 28 Sep 2026 18:24:41 -0700 Subject: [PATCH 180/270] rm chestnut uncompiled alert (#39030) --- openpilot/common/params_keys.h | 1 - openpilot/selfdrive/selfdrived/alerts_offroad.json | 4 ---- openpilot/selfdrive/ui/ui_state.py | 2 -- openpilot/system/hardware/chestnut/status.py | 2 -- 4 files changed, 9 deletions(-) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index f5e82b1d9d..6a334a7020 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -97,7 +97,6 @@ inline static std::unordered_map keys = { {"Offroad_ChestnutNotDetected", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_ChestnutOverheated", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_ChestnutPcieUnavailable", {CLEAR_ON_MANAGER_START, JSON}}, - {"Offroad_ChestnutUncompiled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_ChestnutUpdateFailed", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_ChestnutUsbSlow", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_ConnectivityNeeded", {CLEAR_ON_MANAGER_START, JSON}}, diff --git a/openpilot/selfdrive/selfdrived/alerts_offroad.json b/openpilot/selfdrive/selfdrived/alerts_offroad.json index 66eec0b8a8..0e99aa5418 100644 --- a/openpilot/selfdrive/selfdrived/alerts_offroad.json +++ b/openpilot/selfdrive/selfdrived/alerts_offroad.json @@ -37,10 +37,6 @@ "text": "%1", "severity": 0 }, - "Offroad_ChestnutUncompiled": { - "text": "Chestnut model not compiled. Keep ignition on and reboot the comma.", - "severity": 0 - }, "Offroad_ChestnutUpdateFailed": { "text": "Chestnut update failed. Check the USB cable.", "severity": 0 diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index fecafc6b46..9489673039 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -247,8 +247,6 @@ class UIState: self.always_on_dm = self.params.get_bool("AlwaysOnDM") self.experimental_mode = self.params.get_bool("ExperimentalMode") self.experimental_mode_confirmed = self.params.get_bool("ExperimentalModeConfirmed") - if not self.chestnut_compiled: - self.chestnut_compiled = chestnut_compiled() self.chestnut_active = self.params.get("ChestnutActive") self.chestnut_loading = self.params.get_bool("ChestnutLoading") now = time.monotonic() diff --git a/openpilot/system/hardware/chestnut/status.py b/openpilot/system/hardware/chestnut/status.py index b088c56b22..186e043262 100644 --- a/openpilot/system/hardware/chestnut/status.py +++ b/openpilot/system/hardware/chestnut/status.py @@ -1,7 +1,6 @@ import time from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, is_chestnut_usb_id -from openpilot.selfdrive.modeld.helpers import chestnut_compiled CHESTNUT_RELEASE_BRANCHES = ("release-chestnut", "release-chestnut-staging", "nightly-chestnut") @@ -95,6 +94,5 @@ class ChestnutStatus: pcie_alert = "Chestnut GPU unavailable. PCIe link is not up. Check the GPU is securely seated." set_alert("Offroad_ChestnutPcieUnavailable", self.pcie_failed, pcie_alert) set_alert("Offroad_ChestnutModelError", self.model_failed and not (missing or self.pcie_failed)) - set_alert("Offroad_ChestnutUncompiled", offroad and firmware_ok and not chestnut_compiled()) set_alert("Offroad_ChestnutUpdateFailed", offroad and firmware_failed) self.offroad = offroad From c8fb906815530460ed156f14e09e1f312bb0f851 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Mon, 28 Sep 2026 19:18:41 -0700 Subject: [PATCH 181/270] build nightly-chestnut-dev (#39031) --- Jenkinsfile | 15 ++++++++++++--- openpilot/common/version.py | 4 ++-- openpilot/system/hardware/chestnut/status.py | 2 +- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 576daba734..5facce903f 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -242,9 +242,18 @@ node { } if (env.BRANCH_NAME == '__nightly-chestnut') { - deviceStage("build nightly-chestnut", "mici-chestnut-ci", ["CHESTNUT=1"], [ - step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 PANDA_DEBUG_BUILD=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), - ]) + parallel ( + 'nightly-chestnut': { + deviceStage("build nightly-chestnut", "mici-chestnut-ci", ["CHESTNUT=1"], [ + step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), + ]) + }, + 'nightly-chestnut-dev': { + deviceStage("build nightly-chestnut-dev", "mici-chestnut-ci", ["CHESTNUT=1"], [ + step("build nightly-chestnut-dev", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 PANDA_DEBUG_BUILD=1 RELEASE_BRANCH=nightly-chestnut-dev $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), + ]) + }, + ) } if (!env.BRANCH_NAME.matches(excludeRegex)) { diff --git a/openpilot/common/version.py b/openpilot/common/version.py index 011161b99d..b6f0bc9d3f 100755 --- a/openpilot/common/version.py +++ b/openpilot/common/version.py @@ -10,8 +10,8 @@ from openpilot.common.basedir import BASEDIR from openpilot.common.swaglog import cloudlog from openpilot.common.git import get_commit, get_origin, get_branch, get_short_branch, get_commit_date -RELEASE_BRANCHES = ['release-tizi-staging', 'release-mici-staging', 'release-tizi', 'release-mici', 'nightly'] -TESTED_BRANCHES = RELEASE_BRANCHES + ['devel-staging', 'nightly-dev'] +RELEASE_BRANCHES = ['release-tizi-staging', 'release-mici-staging', 'release-tizi', 'release-mici', 'nightly', 'nightly-chestnut'] +TESTED_BRANCHES = RELEASE_BRANCHES + ['devel-staging', 'nightly-dev', 'nightly-chestnut-dev'] BUILD_METADATA_FILENAME = "build.json" diff --git a/openpilot/system/hardware/chestnut/status.py b/openpilot/system/hardware/chestnut/status.py index 186e043262..4e6c62fd08 100644 --- a/openpilot/system/hardware/chestnut/status.py +++ b/openpilot/system/hardware/chestnut/status.py @@ -3,7 +3,7 @@ import time from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, is_chestnut_usb_id -CHESTNUT_RELEASE_BRANCHES = ("release-chestnut", "release-chestnut-staging", "nightly-chestnut") +CHESTNUT_RELEASE_BRANCHES = ("release-chestnut", "release-chestnut-staging", "nightly-chestnut", "nightly-chestnut-dev") CHESTNUT_POWERED_VOLTAGE = 5000 GPU_TEMP_LIMIT = 100. MEMORY_TEMP_LIMIT = 95. From d2c0ce91ade7014162f0e99d881c9350193efa0a Mon Sep 17 00:00:00 2001 From: Robbe Derks Date: Thu, 1 Oct 2026 13:43:30 +0200 Subject: [PATCH 182/270] pandad: log NMI and hardfault resets (#38737) --- openpilot/cereal/log.capnp | 3 +++ openpilot/selfdrive/pandad/pandad.cc | 2 ++ 2 files changed, 5 insertions(+) diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 60461bde61..5152d787ab 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -573,6 +573,9 @@ struct PandaState @0xa7649e2575e4591e { controlsAllowedRESERVED1 @38 :Bool; controlsAllowedRESERVED2 @39 :Bool; + nmiReset @40 :Bool; + hardfaultReset @41 :Bool; + enum FaultStatus { none @0; faultTemp @1; diff --git a/openpilot/selfdrive/pandad/pandad.cc b/openpilot/selfdrive/pandad/pandad.cc index 8b54ca6eaf..39e11165ba 100644 --- a/openpilot/selfdrive/pandad/pandad.cc +++ b/openpilot/selfdrive/pandad/pandad.cc @@ -127,6 +127,8 @@ void fill_panda_state(cereal::PandaState::Builder &ps, cereal::PandaState::Panda ps.setInterruptLoad(health.interrupt_load_pkt / 255.0f); ps.setFanPower(health.fan_power); ps.setSafetyRxChecksInvalid((health.flags_pkt & HEALTH_FLAG_SAFETY_RX_CHECKS_INVALID) != 0U); + ps.setNmiReset((health.flags_pkt & HEALTH_FLAG_NMI_RESET) != 0U); + ps.setHardfaultReset((health.flags_pkt & HEALTH_FLAG_HARDFAULT_RESET) != 0U); ps.setSpiErrorCount(health.spi_error_count_pkt); ps.setSbu1Voltage(health.sbu1_voltage_mV / 1000.0f); ps.setSbu2Voltage(health.sbu2_voltage_mV / 1000.0f); From 16322aef167fe14de8af28a9e437101ed3c4dac5 Mon Sep 17 00:00:00 2001 From: discountchubbs Date: Wed, 30 Sep 2026 14:44:49 -0700 Subject: [PATCH 183/270] models: per model tg ref --- .github/workflows/build-default-models.yaml | 1 + .../download-hf-model-chunks/action.yml | 21 ++++++- .../workflows/sunnypilot-build-prebuilt.yaml | 31 +++++++---- opendbc_repo | 2 +- .../sunnypilot/modeld_v2/compile_modeld.py | 55 ++++++++++++++----- panda | 2 +- release/ci/upload_default_model.py | 25 ++++++--- tinygrad_repo | 2 +- 8 files changed, 103 insertions(+), 36 deletions(-) diff --git a/.github/workflows/build-default-models.yaml b/.github/workflows/build-default-models.yaml index 53cf933359..8ac48c4021 100644 --- a/.github/workflows/build-default-models.yaml +++ b/.github/workflows/build-default-models.yaml @@ -382,6 +382,7 @@ jobs: 'short_name': 'DMMODEL', 'display_name': '${{ needs.resolve.outputs.model_name }}', 'ref': '${{ needs.resolve.outputs.onnx_ref }}', + 'tinygrad_ref': '${{ needs.resolve.outputs.tinygrad_ref }}', 'runner': 'tinygrad', 'build_time': datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'), 'models': [{ diff --git a/.github/workflows/download-hf-model-chunks/action.yml b/.github/workflows/download-hf-model-chunks/action.yml index 677385f1d6..83c0dcd133 100644 --- a/.github/workflows/download-hf-model-chunks/action.yml +++ b/.github/workflows/download-hf-model-chunks/action.yml @@ -11,6 +11,10 @@ inputs: dest_dir: description: Destination directory for downloaded chunks required: true + tinygrad_ref: + description: 'Tinygrad ref to match bundle against' + required: false + default: '' runs: using: composite @@ -21,6 +25,7 @@ runs: HF_REPO: ${{ inputs.hf_repo }} MODELS_JSON: ${{ inputs.models }} DEST_DIR: ${{ inputs.dest_dir }} + TINYGRAD_REF: ${{ inputs.tinygrad_ref }} run: | set -eo pipefail DOWNLOAD_LIST=$(mktemp) @@ -30,7 +35,21 @@ runs: local JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_PATH}/default_models.json" local DEFAULTS BUNDLE ARTIFACT BASE_URL NUM_CHUNKS DEFAULTS=$(curl -fsSL "$JSON_URL") - BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)') + if [ -n "$TINYGRAD_REF" ]; then + BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" --arg tg "$TINYGRAD_REF" ' + . as $root | [.bundles[] | select(.onnx_sha256 == $hash and (.tinygrad_ref == $tg or (.tinygrad_ref == null and $root.tinygrad_ref == $tg)))] | .[0] // empty + ') + else + BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" ' + [.bundles[] | select(.onnx_sha256 == $hash)] | .[0] // empty + ') + fi + + if [ -z "$BUNDLE" ] || [ "$BUNDLE" = "null" ]; then + echo "::error::No model bundle found matching ONNX hash $ONNX_HASH (tinygrad ref: $TINYGRAD_REF) in $HF_PATH" + return 1 + fi + ARTIFACT=$(echo "$BUNDLE" | jq -r '.models[0].artifact') BASE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url' | sed 's|/[^/]*$||') NUM_CHUNKS=$(echo "$ARTIFACT" | jq -r '.chunks | length') diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index 450023a03f..7fdea327b9 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -224,6 +224,7 @@ jobs: cancel-in-progress: false outputs: onnx_sha256: ${{ steps.resolve.outputs.onnx_sha256 }} + tinygrad_ref: ${{ steps.resolve.outputs.tinygrad_ref }} env: GH_REPO: ${{ github.repository }} HF_REPO: sunnypilot/sunnypilot_models_v1 @@ -239,17 +240,19 @@ jobs: ONNX_HASH=$(gh api "repos/${GH_REPO}/git/blobs/${BLOB_SHA}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2) echo "big PKL hash: $ONNX_HASH" [ -n "$ONNX_HASH" ] || { echo "::error::Failed to extract big PKL hash"; exit 1; } + echo "onnx_sha256=$ONNX_HASH" >> "$GITHUB_OUTPUT" TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha') echo "tinygrad ref: $TINYGRAD_REF" + echo "tinygrad_ref=$TINYGRAD_REF" >> "$GITHUB_OUTPUT" JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json" check_defaults() { DEFAULTS=$(curl -fsSL "${JSON_URL}?t=$(date +%s)" 2>/dev/null) || return 1 - TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null) - [ "$TINYGRAD_MATCH" = "true" ] || return 1 - BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null) + BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" --arg ref "$TINYGRAD_REF" ' + . as $root | [.bundles[] | select(.onnx_sha256 == $hash and (.tinygrad_ref == $ref or (.tinygrad_ref == null and $root.tinygrad_ref == $ref)))] | .[0] // empty + ' 2>/dev/null) [ -n "$BUNDLE" ] && [ "$BUNDLE" != "null" ] } @@ -307,6 +310,7 @@ jobs: cancel-in-progress: false outputs: driving_onnx_sha256: ${{ steps.resolve.outputs.driving_onnx_sha256 }} + tinygrad_ref: ${{ steps.resolve.outputs.tinygrad_ref }} env: GH_REPO: ${{ github.repository }} HF_REPO: sunnypilot/sunnypilot_models_v1 @@ -325,15 +329,16 @@ jobs: TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha') echo "tinygrad ref: $TINYGRAD_REF" + echo "tinygrad_ref=$TINYGRAD_REF" >> "$GITHUB_OUTPUT" JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json" check_defaults() { DEFAULTS=$(curl -fsSL "${JSON_URL}?t=$(date +%s)" 2>/dev/null) || return 1 - TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null) - [ "$TINYGRAD_MATCH" = "true" ] || return 1 - DRIVING=$(echo "$DEFAULTS" | jq --arg hash "$DRIVING_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null) - [ -n "$DRIVING" ] && [ "$DRIVING" != "null" ] || return 1 + DRIVING=$(echo "$DEFAULTS" | jq --arg hash "$DRIVING_HASH" --arg ref "$TINYGRAD_REF" ' + . as $root | [.bundles[] | select(.onnx_sha256 == $hash and (.tinygrad_ref == $ref or (.tinygrad_ref == null and $root.tinygrad_ref == $ref)))] | .[0] // empty + ' 2>/dev/null) + [ -n "$DRIVING" ] && [ "$DRIVING" != "null" ] } if check_defaults; then @@ -390,6 +395,7 @@ jobs: cancel-in-progress: false outputs: dm_onnx_sha256: ${{ steps.resolve.outputs.dm_onnx_sha256 }} + tinygrad_ref: ${{ steps.resolve.outputs.tinygrad_ref }} env: GH_REPO: ${{ github.repository }} HF_REPO: sunnypilot/sunnypilot_models_v1 @@ -408,15 +414,16 @@ jobs: TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha') echo "tinygrad ref: $TINYGRAD_REF" + echo "tinygrad_ref=$TINYGRAD_REF" >> "$GITHUB_OUTPUT" JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json" check_defaults() { DEFAULTS=$(curl -fsSL "${JSON_URL}?t=$(date +%s)" 2>/dev/null) || return 1 - TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null) - [ "$TINYGRAD_MATCH" = "true" ] || return 1 - DM=$(echo "$DEFAULTS" | jq --arg hash "$DM_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null) - [ -n "$DM" ] && [ "$DM" != "null" ] || return 1 + DM=$(echo "$DEFAULTS" | jq --arg hash "$DM_HASH" --arg ref "$TINYGRAD_REF" ' + . as $root | [.bundles[] | select(.onnx_sha256 == $hash and (.tinygrad_ref == $ref or (.tinygrad_ref == null and $root.tinygrad_ref == $ref)))] | .[0] // empty + ' 2>/dev/null) + [ -n "$DM" ] && [ "$DM" != "null" ] } if check_defaults; then @@ -501,6 +508,7 @@ jobs: with: hf_repo: sunnypilot/sunnypilot_models_v1 dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models + tinygrad_ref: ${{ needs.prepare_dm_model.outputs.tinygrad_ref }} models: | [ {"hf_path": "models/defaults/small", "onnx_hash": "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}", "canonical": "driving_tinygrad.pkl"}, @@ -570,6 +578,7 @@ jobs: with: hf_repo: sunnypilot/sunnypilot_models_v1 dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models + tinygrad_ref: ${{ needs.prepare_dm_model.outputs.tinygrad_ref }} models: | [ {"hf_path": "models/defaults/small", "onnx_hash": "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}", "canonical": "driving_tinygrad.pkl"}, diff --git a/opendbc_repo b/opendbc_repo index b4fcf1aa84..b2acec1dc7 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit b4fcf1aa845905b692270e3a1d4a46a57ebaf303 +Subproject commit b2acec1dc7f15cfb258aa48415168485e29cc4ff diff --git a/openpilot/sunnypilot/modeld_v2/compile_modeld.py b/openpilot/sunnypilot/modeld_v2/compile_modeld.py index 4b27846d28..2b7256aeb9 100755 --- a/openpilot/sunnypilot/modeld_v2/compile_modeld.py +++ b/openpilot/sunnypilot/modeld_v2/compile_modeld.py @@ -11,6 +11,7 @@ import atexit import codecs import gc import math +import multiprocessing as mp import os import pickle import shutil @@ -375,6 +376,23 @@ def _load_policy_runners(args: argparse.Namespace) -> tuple[list, list]: return runners, keys +def _compile_warp_resolution_worker(cam_w, cam_h, model_w, model_h, benchmark_runs, result_path): + nv12 = stock.NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) + WARP_DEV = os.getenv('WARP_DEV', Device.DEFAULT) + make_random_warp_inputs = partial(make_random_images, keys=['frame', 'big_frame'], shape=nv12.size, device=WARP_DEV) + warp = TinyJit(stock.make_warp(nv12, model_w, model_h), prune=True) + + def cleanup_warp(): + nonlocal warp + warp = None + compiled_jit = compile_jit(warp, WARP_INPUTS, make_warp_queues, make_random_inputs=make_random_warp_inputs, + benchmark_runs=benchmark_runs, clear_refs=cleanup_warp) + + result_data = (compiled_jit, Device.DEFAULT) + with open(result_path, "wb") as f: + dump_oob(result_data, f) + + if __name__ == "__main__": if 'USB' in os.getenv('DEV', '') or os.getenv('CHESTNUT'): from openpilot.system.hardware.chestnut.flash import link_up @@ -469,7 +487,6 @@ if __name__ == "__main__": assert isinstance(feat_meta, dict) features_slice = feat_meta['output_slices']['hidden_state'] is_supercombo = vision_runner is None - gc.collect() print(f"Compiling run_policy JIT (model_size={model_w}x{model_h}, frame_skip={derived_frame_skip})...") run_policy_func = make_run_policy(vision_runner, policy_runners, features_slice, derived_frame_skip, all_shapes) @@ -478,24 +495,36 @@ if __name__ == "__main__": WARP_DEV = os.getenv('WARP_DEV', Device.DEFAULT) make_random_model_inputs = partial(make_random_images, keys=['warped'], shape=(2, 6, model_h // 2, model_w // 2), device=WARP_DEV) - output_data['run_policy'] = compile_jit(run_policy_jit, POLICY_INPUTS, make_policy_queues, - make_random_inputs=make_random_model_inputs, benchmark_runs=args.benchmark_runs) + def cleanup_policy(): + global run_policy_jit, run_policy_func, vision_runner, policy_runners + run_policy_jit, run_policy_func, vision_runner, policy_runners = None, None, None, None + output_data['run_policy'] = compile_jit(run_policy_jit, POLICY_INPUTS, make_policy_queues, + make_random_inputs=make_random_model_inputs, benchmark_runs=args.benchmark_runs, clear_refs=cleanup_policy) + + ctx = mp.get_context('spawn') output_data['input_devices'] = {} - WARP_DEV = os.getenv('WARP_DEV', Device.DEFAULT) + tmp_files = [] for cam_w, cam_h in set(args.camera_resolutions): print(f"Compiling warp JIT for {cam_w}x{cam_h}") - nv12 = stock.NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - make_random_warp_inputs = partial(make_random_images, keys=['frame', 'big_frame'], shape=nv12.size, device=WARP_DEV) - warp = TinyJit(stock.make_warp(nv12, model_w, model_h), prune=True) - output_data[(cam_w, cam_h)] = compile_jit(warp, WARP_INPUTS, make_warp_queues, - make_random_inputs=make_random_warp_inputs, - benchmark_runs=args.benchmark_runs) - output_data['input_devices']['warp'] = Device.DEFAULT - gc.collect() + tmp_res = tempfile.NamedTemporaryFile(delete=False, suffix=".pkl", dir=".") + tmp_res.close() + + p = ctx.Process(target=_compile_warp_resolution_worker, args=(cam_w, cam_h, model_w, model_h, args.benchmark_runs, tmp_res.name)) + p.start() + p.join() + if p.exitcode != 0: + raise RuntimeError(f"Warp compilation worker failed for {cam_w}x{cam_h}") + + tmp_files.append((cam_w, cam_h, tmp_res.name)) + for cam_w, cam_h, tmp_name in tmp_files: + with open(tmp_name, "rb") as f: + compiled_jit, dev_name = load_oob(f) + output_data[(cam_w, cam_h)] = compiled_jit + output_data['input_devices']['warp'] = dev_name + os.remove(tmp_name) output_data['metadata']['warp_dev'] = Device.DEFAULT - gc.collect() with open(args.output, "wb") as file: dump_oob(output_data, file) diff --git a/panda b/panda index 7eab6d2712..4230530f5e 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit 7eab6d2712d1bbe0c37ff3c6f6cd62520fdf5206 +Subproject commit 4230530f5ec4c7f11971f868ae9930b3547346eb diff --git a/release/ci/upload_default_model.py b/release/ci/upload_default_model.py index ca94f9ffbd..2abd5087c0 100644 --- a/release/ci/upload_default_model.py +++ b/release/ci/upload_default_model.py @@ -22,6 +22,21 @@ def hash_file(path: str) -> str: return digest.hexdigest() +def update_defaults_json(defaults_json: dict, bundle: dict, dedup_key: str, tinygrad_ref: str) -> dict: + def matches_bundle(target_bundle): + bundle_hash = target_bundle.get("onnx_sha256") + bundle_tinygrad_ref = target_bundle.get("tinygrad_ref") or defaults_json.get("tinygrad_ref") + return bundle_hash == dedup_key and bundle_tinygrad_ref == tinygrad_ref + + defaults_json["tinygrad_ref"] = tinygrad_ref + existing_index = next((i for i, b in enumerate(defaults_json.get("bundles", [])) if matches_bundle(b)), None) + if existing_index is not None: + defaults_json["bundles"][existing_index] = bundle + else: + defaults_json.setdefault("bundles", []).append(bundle) + return defaults_json + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--hf-repo", required=True) @@ -58,6 +73,7 @@ def main(): if onnx_sha256 is not None: bundle['onnx_sha256'] = onnx_sha256 bundle['onnx_ref'] = args.onnx_ref + bundle['tinygrad_ref'] = args.tinygrad_ref dedup_key = onnx_sha256 or bundle.get('onnx_sha256', '') artifact = bundle['models'][0]['artifact'] @@ -82,14 +98,7 @@ def main(): except Exception: defaults_json = {"tinygrad_ref": args.tinygrad_ref, "bundles": []} - defaults_json['tinygrad_ref'] = args.tinygrad_ref - - existing_idx = next((i for i, b in enumerate(defaults_json['bundles']) - if b.get('onnx_sha256') == dedup_key), None) - if existing_idx is not None: - defaults_json['bundles'][existing_idx] = bundle - else: - defaults_json['bundles'].append(bundle) + update_defaults_json(defaults_json, bundle, dedup_key, args.tinygrad_ref) print(json.dumps(defaults_json, indent=2)) diff --git a/tinygrad_repo b/tinygrad_repo index 9cd40014f6..fe5d3169ba 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit 9cd40014f651ac2472b3b5fc3b12178ab4bb1c66 +Subproject commit fe5d3169ba4f41d0947ad174925f413cbea9d056 From 7ee974ab5bec3241d821e15fc5d2e980a80d4f9c Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 1 Oct 2026 12:03:32 -0700 Subject: [PATCH 184/270] fix low speed curvature saturation alert (#39034) fix curvature saturation alert --- .../selfdrive/controls/lib/latcontrol_curvature.py | 11 +++++++++-- openpilot/selfdrive/controls/tests/test_latcontrol.py | 5 ++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/openpilot/selfdrive/controls/lib/latcontrol_curvature.py b/openpilot/selfdrive/controls/lib/latcontrol_curvature.py index ec3ad1bf16..398c32f3d5 100644 --- a/openpilot/selfdrive/controls/lib/latcontrol_curvature.py +++ b/openpilot/selfdrive/controls/lib/latcontrol_curvature.py @@ -1,11 +1,13 @@ import math +import numpy as np +from collections import deque from openpilot.cereal import log from openpilot.common.pid import PIDController from openpilot.selfdrive.controls.lib.latcontrol import LatControl from openpilot.selfdrive.controls.lib.drive_helpers import MAX_CURVATURE -CURVATURE_SATURATION_THRESHOLD = 1e-3 # 1/m +LAT_ACCEL_SATURATION_THRESHOLD = 0.5 # m/s^2 class LatControlCurvature(LatControl): @@ -20,6 +22,8 @@ class LatControlCurvature(LatControl): else: self.pid = None self.kf = 1. + self.desired_curvature_buffer_len = int(1. / self.dt) + self.desired_curvature_buffer = deque([0.] * self.desired_curvature_buffer_len, maxlen=self.desired_curvature_buffer_len) def reset(self): super().reset() @@ -30,6 +34,9 @@ class LatControlCurvature(LatControl): curvature_log = log.ControlsState.LateralCurvatureState.new_message() actual_curvature = -VM.calc_curvature(math.radians(CS.steeringAngleDeg - params.angleOffsetDeg), CS.vEgo, params.roll) error = desired_curvature - actual_curvature + self.desired_curvature_buffer.append(desired_curvature) + delay_frames = int(np.clip(lat_delay / self.dt + 1, 1, self.desired_curvature_buffer_len)) + expected_curvature = self.desired_curvature_buffer[-delay_frames] if not active: output_curvature = 0.0 @@ -53,6 +60,6 @@ class LatControlCurvature(LatControl): curvature_log.actualCurvature = float(actual_curvature) curvature_log.desiredCurvature = float(desired_curvature) curvature_log.output = float(output_curvature) - curvature_log.saturated = bool(self._check_saturation(abs(error) > CURVATURE_SATURATION_THRESHOLD, CS, + curvature_log.saturated = bool(self._check_saturation(abs(expected_curvature - actual_curvature) * CS.vEgo ** 2 > LAT_ACCEL_SATURATION_THRESHOLD, CS, False, curvature_limited)) return 0.0, float(output_curvature), curvature_log diff --git a/openpilot/selfdrive/controls/tests/test_latcontrol.py b/openpilot/selfdrive/controls/tests/test_latcontrol.py index 2b7a0cf695..47dd4f7f8e 100644 --- a/openpilot/selfdrive/controls/tests/test_latcontrol.py +++ b/openpilot/selfdrive/controls/tests/test_latcontrol.py @@ -8,17 +8,20 @@ from opendbc.car.honda.values import CAR as HONDA from opendbc.car.toyota.values import CAR as TOYOTA from opendbc.car.nissan.values import CAR as NISSAN from opendbc.car.gm.values import CAR as GM +from opendbc.car.volkswagen.values import CAR as VOLKSWAGEN from opendbc.car.vehicle_model import VehicleModel from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle +from openpilot.selfdrive.controls.lib.latcontrol_curvature import LatControlCurvature class TestLatControl(OpenpilotTestCase): @parameterized.expand([(HONDA.HONDA_CIVIC, LatControlPID), (TOYOTA.TOYOTA_RAV4, LatControlTorque), - (NISSAN.NISSAN_LEAF, LatControlAngle), (GM.CHEVROLET_BOLT_EUV, LatControlTorque)]) + (NISSAN.NISSAN_LEAF, LatControlAngle), (GM.CHEVROLET_BOLT_EUV, LatControlTorque), + (VOLKSWAGEN.VOLKSWAGEN_ID4_MK2, LatControlCurvature)]) def test_saturation(self, car_name, controller): CarInterface = interfaces[car_name] CP = CarInterface.get_non_essential_params(car_name) From 78dccf0b482ea1298fd1f6fa59b0554187b02ce6 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 1 Oct 2026 16:19:08 -0700 Subject: [PATCH 185/270] ui: lead bar (#39033) * ui: lead bar * cleanup * add width * show on brake --- .../ui/mici/onroad/model_renderer.py | 121 ++++++++---------- 1 file changed, 53 insertions(+), 68 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py index dd0d6d9734..e927fa506e 100644 --- a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py @@ -1,11 +1,12 @@ import colorsys import numpy as np import pyray as rl -from openpilot.cereal import messaging +from openpilot.cereal import log, messaging from opendbc.car.structs import car from dataclasses import dataclass, field from openpilot.common.params import Params from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.selfdrive.controls.radard import RADAR_TO_CAMERA from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.mici.onroad import blend_colors @@ -16,6 +17,8 @@ from openpilot.system.ui.widgets import Widget CLIP_MARGIN = 500 MIN_DRAW_DISTANCE = 10.0 MAX_DRAW_DISTANCE = 100.0 +LEAD_BAR_LENGTH = 12.0 # px +LEAD_BAR_WIDTH = 1.8 # m THROTTLE_COLORS = [ rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4) @@ -42,11 +45,12 @@ class ModelPoints: projected_points: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32)) -@dataclass class LeadVehicle: - glow: list[tuple[float, float]] = field(default_factory=list) - chevron: list[tuple[float, float]] = field(default_factory=list) - fill_alpha: int = 0 + def __init__(self): + self.bar = np.empty((0, 2), dtype=np.float32) + self.d_filter = FirstOrderFilter(0.0, 0.2, 1 / gui_app.target_fps, initialized=False) + self.y_filter = FirstOrderFilter(0.0, 0.2, 1 / gui_app.target_fps, initialized=False) + self.fade_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) class ModelRenderer(Widget): @@ -134,8 +138,6 @@ class ModelRenderer(Widget): return self._update_model(lead_one, path_x_array) - if render_lead_indicator: - self._update_leads(radar_state, path_x_array) self._transform_dirty = False # Draw elements (hide when disengaged) @@ -143,8 +145,11 @@ class ModelRenderer(Widget): self._draw_lane_lines() self._draw_path(sm) - # if render_lead_indicator and radar_state: - # self._draw_lead_indicator() + if render_lead_indicator: + self._update_leads(sm) + self._draw_lead_indicator() + else: + self._lead_vehicles = [LeadVehicle(), LeadVehicle()] def _update_raw_points(self, model): """Update raw 3D points from model data""" @@ -160,21 +165,45 @@ class ModelRenderer(Widget): self._road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32) self._acceleration_x = np.array(model.acceleration.x, dtype=np.float32) - def _update_leads(self, radar_state, path_x_array): - """Update positions of lead vehicles""" - self._lead_vehicles = [LeadVehicle(), LeadVehicle()] - leads = [radar_state.leadOne, radar_state.leadTwo] + def _update_leads(self, sm): + plan = sm['longitudinalPlan'] + if plan.longitudinalPlanSource == log.LongitudinalPlan.LongitudinalPlanSource.e2e and len(sm['modelV2'].leadsV3) > 1: + leads = [(lead.prob > 0.5, lead.x[0], -lead.y[0]) for lead in list(sm['modelV2'].leadsV3)[:2]] + else: + radar = sm['radarState'] + leads = [(lead.present, lead.dRel + RADAR_TO_CAMERA, lead.yRel) for lead in (radar.leadOne, radar.leadTwo)] - for i, lead_data in enumerate(leads): - if lead_data and lead_data.present: - d_rel, y_rel, v_rel = lead_data.dRel, lead_data.yRel, lead_data.vRel - idx = self._get_path_length_idx(path_x_array, d_rel) + # both leads can be the same vehicle + if leads[0][0] and abs(leads[1][1] - leads[0][1]) < 3.0: + leads[1] = (False, 0.0, 0.0) - # Get z-coordinate from path at the lead vehicle position - z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0 - point = self._map_to_screen(d_rel, -y_rel, z + self._path_offset_z) - if point: - self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect) + ss, cs = sm['selfdriveState'], sm['carState'] + # braking disengages without making openpilot unavailable + available = ss.enabled or ss.engageable or cs.brakePressed + lane = (self._lane_lines[1].raw_points + self._lane_lines[2].raw_points) / 2 + opacity = 0.4 if ui_state.status == UIStatus.DISENGAGED else 0.8 + for lead, (present, d_rel, y_rel) in zip(self._lead_vehicles, leads, strict=True): + visible = available and present and d_rel < MAX_DRAW_DISTANCE and len(lane) > 0 + # snap to a new vehicle instead of sliding over + if not visible or abs(y_rel - lead.y_filter.x) > 3.0: + lead.d_filter.initialized = lead.y_filter.initialized = False + lead.fade_filter.update(opacity if visible else 0.0) + if visible: + lead.bar = self._get_lead_bar(lane, lead.d_filter.update(d_rel), lead.y_filter.update(y_rel)) + + def _get_lead_bar(self, lane, d_rel, y_rel): + # bar on the road behind the lead, following the lane + x = np.array([d_rel, d_rel - min(6.0, 0.25 * d_rel)]) + y = np.interp(x, lane[:, 0], lane[:, 1]) - np.interp(d_rel, lane[:, 0], lane[:, 1]) - y_rel + z = np.interp(x, self._path.raw_points[:, 0], self._path.raw_points[:, 2]) + self._path_offset_z + corners = np.vstack((np.column_stack((x, y + LEAD_BAR_WIDTH / 2, z)), np.column_stack((x, y - LEAD_BAR_WIDTH / 2, z))[::-1])) + pts = self._car_space_transform @ corners.T + bar = (pts[:2] / pts[2]).T + + far, near = bar[[0, 3]], bar[[1, 2]] + length = np.linalg.norm(near.mean(axis=0) - far.mean(axis=0)) + bar[[1, 2]] = far + (near - far) * np.clip(length, 3.0, LEAD_BAR_LENGTH) / length + return bar.astype(np.float32) def _update_model(self, lead, path_x_array): """Update model visualization data based on model message""" @@ -259,30 +288,6 @@ class ModelRenderer(Widget): self._exp_gradient.colors = segment_colors self._exp_gradient.stops = gradient_stops - def _update_lead_vehicle(self, d_rel, v_rel, point, rect): - speed_buff, lead_buff = 10.0, 40.0 - - # Calculate fill alpha - fill_alpha = 0 - if d_rel < lead_buff: - fill_alpha = 255 * (1.0 - (d_rel / lead_buff)) - if v_rel < 0: - fill_alpha += 255 * (-1 * (v_rel / speed_buff)) - fill_alpha = min(fill_alpha, 255) - - # Calculate size and position - sz = np.clip((25 * 30) / (d_rel / 3 + 30), 15.0, 30.0) * 1 - x = np.clip(point[0], 0.0, rect.width - sz / 2) - y = min(point[1], rect.height - sz * 0.6) - - g_xo = sz / 5 - g_yo = sz / 10 - - glow = [(x + (sz * 1.35) + g_xo, y + sz + g_yo), (x, y - g_yo), (x - (sz * 1.35) - g_xo, y + sz + g_yo)] - chevron = [(x + (sz * 1.25), y + sz), (x, y), (x - (sz * 1.25), y + sz)] - - return LeadVehicle(glow=glow, chevron=chevron, fill_alpha=int(fill_alpha)) - def _get_ll_color(self, prob: float, adjacent: bool, left: bool): alpha = np.clip(prob, 0.0, 0.7) if adjacent: @@ -360,13 +365,9 @@ class ModelRenderer(Widget): draw_polygon(self._rect, path_pts, gradient=gradient) def _draw_lead_indicator(self): - # Draw lead vehicles if available + offset = np.array([self._rect.x, self._rect.y], dtype=np.float32) for lead in self._lead_vehicles: - if not lead.glow or not lead.chevron: - continue - - rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255)) - rl.draw_triangle_fan(lead.chevron, len(lead.chevron), rl.Color(201, 34, 49, lead.fill_alpha)) + draw_polygon(self._rect, lead.bar + offset, rl.Color(255, 255, 255, int(255 * lead.fade_filter.x))) @staticmethod def _get_path_length_idx(pos_x_array: np.ndarray, path_height: float) -> int: @@ -376,22 +377,6 @@ class ModelRenderer(Widget): indices = np.where(pos_x_array <= path_height)[0] return indices[-1] if indices.size > 0 else 0 - def _map_to_screen(self, in_x, in_y, in_z): - """Project a point in car space to screen space""" - input_pt = np.array([in_x, in_y, in_z]) - pt = self._car_space_transform @ input_pt - - if abs(pt[2]) < 1e-6: - return None - - x, y = pt[0] / pt[2], pt[1] / pt[2] - - clip = self._clip_region - if not (clip.x <= x <= clip.x + clip.width and clip.y <= y <= clip.y + clip.height): - return None - - return (x, y) - def _map_line_to_polygon(self, line: np.ndarray, y_off: float, z_off: float, max_idx: int, allow_invert: bool = True) -> np.ndarray: """Convert 3D line to 2D polygon for rendering.""" if line.shape[0] == 0: From ec95db3f1fa19f76940497fedfdb62e09ea19912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Fri, 2 Oct 2026 18:02:42 -0700 Subject: [PATCH 186/270] Bump opendbc (#39035) --- opendbc_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendbc_repo b/opendbc_repo index 4134c0d1f5..35f7e08134 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 4134c0d1f5e8f695e35ea5fedbe88f6d0c3afb76 +Subproject commit 35f7e0813462607ef1d703e52313e7571e31405d From 01a670d6b2fc41c0fc79300b200906e4058f8d33 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Mon, 5 Oct 2026 12:03:45 -0700 Subject: [PATCH 187/270] ui: fix keyboard text overflow gradient --- openpilot/selfdrive/ui/mici/widgets/dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/ui/mici/widgets/dialog.py b/openpilot/selfdrive/ui/mici/widgets/dialog.py index d5da6178f1..80625aa451 100644 --- a/openpilot/selfdrive/ui/mici/widgets/dialog.py +++ b/openpilot/selfdrive/ui/mici/widgets/dialog.py @@ -183,7 +183,7 @@ class BigInputDialog(BigDialogBase): # draw gradient on left side to indicate more text if text_size.x > text_field_rect.width: rl.draw_rectangle_gradient_ex(rl.Rectangle(text_field_rect.x, text_field_rect.y, 80, text_field_rect.height), - rl.BLACK, rl.BLANK, rl.BLANK, rl.BLACK) + rl.BLACK, rl.BLACK, rl.BLANK, rl.BLANK) # draw cursor blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2 From 910c08e0828c54ce7919ae14606166779a52bf94 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Mon, 5 Oct 2026 15:31:58 -0700 Subject: [PATCH 188/270] Revert #38464 (#39038) * Revert #38464 Reverts aa0ac919baa0df2fa792e281246d4a1a1e6a1652. --- openpilot/selfdrive/pandad/spi.cc | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/openpilot/selfdrive/pandad/spi.cc b/openpilot/selfdrive/pandad/spi.cc index 369032a533..f54c26e506 100644 --- a/openpilot/selfdrive/pandad/spi.cc +++ b/openpilot/selfdrive/pandad/spi.cc @@ -29,12 +29,6 @@ enum SpiError { const unsigned int SPI_ACK_TIMEOUT = 500; // milliseconds const std::string SPI_DEVICE = "/dev/spidev0.0"; -// TODO: fix SPI turnaround synchronization at the protocol level. -static uint64_t spi_last_bus_activity_ns = 0; // protected by hw_lock - -static void wait_for_spi_turnaround(uint64_t start_ns) { - while ((nanos_since_boot() - start_ns) < 400000) {} -} class LockEx { public: @@ -325,8 +319,6 @@ int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx assert(tx_len < SPI_BUF_SIZE); assert(max_rx_len < SPI_BUF_SIZE); - wait_for_spi_turnaround(spi_last_bus_activity_ns); - xfer_count++; header = { .sync = SPI_SYNC, @@ -355,7 +347,6 @@ int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx if (ret < 0) { goto fail; } - wait_for_spi_turnaround(nanos_since_boot()); // Send data if (tx_data != NULL) { @@ -398,7 +389,6 @@ int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx memcpy(rx_data, rx_buf + 3, rx_data_len); } - spi_last_bus_activity_ns = nanos_since_boot(); return rx_data_len; fail: @@ -413,7 +403,6 @@ fail: } } - spi_last_bus_activity_ns = nanos_since_boot(); if (ret >= 0) ret = -1; return ret; } From 4bcf73282e35c49fa82e8f1ca9e10232a520e8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Mon, 5 Oct 2026 17:58:45 -0700 Subject: [PATCH 189/270] ResAction (#39037) * 7a933c1c-b3ba-4b31-be45-d2a03ca9fa9d/12864 * compiled --- openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl | 4 ++-- .../modeld/models/big_driving_warp_1344x760_tinygrad.pkl | 4 ++-- .../modeld/models/big_driving_warp_1928x1208_tinygrad.pkl | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl index 91125f3f21..7925e6d828 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4937fc6f04367b28328fe3bc0d1463a245725d039ddaab29406cc565fd5c8ed4 -size 773082315 +oid sha256:cc75858d5e007b725b6264e194da4321d7874a212dc631b8f40a215dfd172cd2 +size 799992286 diff --git a/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl index fdcaba2f00..baa4f72f41 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6334d6b92a2cc095bfb1430a252aa2ede1902605d0f9ba89224aa5a87c7dc7c0 -size 863472 +oid sha256:e80410f65706472e72150cfdcb0087595782e8aa366149286672511a6224ef54 +size 864120 diff --git a/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl index 91c4096993..242cc0fb9a 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bda11a9c2e87636db364862ca68ebe4ff9d7baeeb39c0c7e2010b702ed543283 -size 863216 +oid sha256:54dff4bc4a64a8615823bb4906429a9db30f4b9fca9b8a56e9c84100227608bb +size 863864 From cce54f20d52c381499ae16119cb83c710c1f1083 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Mon, 5 Oct 2026 18:05:05 -0700 Subject: [PATCH 190/270] deprecate unused initData fields --- openpilot/cereal/log.capnp | 24 +++++++++++------------- openpilot/system/loggerd/logger.cc | 1 - 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 5152d787ab..48bbdb9797 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -167,12 +167,7 @@ struct InitData { gitSrcCommit @23 :Text; gitSrcCommitDate @24 :Text; - androidProperties @16 :Map(Text, Text); - - pandaInfo @8 :PandaInfo; - dirty @9 :Bool; - passive @12 :Bool; params @17 :Map(Text, Data); commands @19 :Map(Text, Data); @@ -190,6 +185,17 @@ struct InitData { mici @7; # comma four } + deprecated :group { + gctx @1 :Text; + androidBuildInfo @5 :Deprecated.AndroidBuildInfo; + androidSensors @6 :List(Deprecated.AndroidSensor); + chffrAndroidExtra @7 :ChffrAndroidExtra; + pandaInfo @8 :PandaInfo; + passive @12 :Bool; + iosBuildInfo @14 :Deprecated.IosBuildInfo; + androidProperties @16 :Map(Text, Text); + } + struct PandaInfo { hasPanda @0 :Bool; dongleId @1 :Text; @@ -200,14 +206,6 @@ struct InitData { struct ChffrAndroidExtra { allCameraCharacteristics @0 :Map(Text, Text); } - - deprecated :group { - gctx @1 :Text; - androidBuildInfo @5 :Deprecated.AndroidBuildInfo; - androidSensors @6 :List(Deprecated.AndroidSensor); - chffrAndroidExtra @7 :ChffrAndroidExtra; - iosBuildInfo @14 :Deprecated.IosBuildInfo; - } } struct FrameData { diff --git a/openpilot/system/loggerd/logger.cc b/openpilot/system/loggerd/logger.cc index f553760016..f9371314d3 100644 --- a/openpilot/system/loggerd/logger.cc +++ b/openpilot/system/loggerd/logger.cc @@ -47,7 +47,6 @@ kj::Array logger_build_init_data(bool route_log) { init.setGitCommitDate(params_map["GitCommitDate"]); init.setGitBranch(params_map["GitBranch"]); init.setGitRemote(params_map["GitRemote"]); - init.setPassive(false); init.setDongleId(params_map["DongleId"]); // for prebuilt branches From 84f9bdbc10df0184976c14457bddab008b141208 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Mon, 5 Oct 2026 18:19:04 -0700 Subject: [PATCH 191/270] jp: add initData tab (#39039) --- openpilot/tools/jotpluggler/app.cc | 6 + openpilot/tools/jotpluggler/app.h | 16 +++ openpilot/tools/jotpluggler/init_data.cc | 113 +++++++++++++++++++ openpilot/tools/jotpluggler/render.cc | 2 +- openpilot/tools/jotpluggler/runtime.cc | 4 +- openpilot/tools/jotpluggler/sketch_layout.cc | 22 ++++ openpilot/tools/jotpluggler/stream.cc | 1 + 7 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 openpilot/tools/jotpluggler/init_data.cc diff --git a/openpilot/tools/jotpluggler/app.cc b/openpilot/tools/jotpluggler/app.cc index e6ba696bae..5cdcff5ed2 100644 --- a/openpilot/tools/jotpluggler/app.cc +++ b/openpilot/tools/jotpluggler/app.cc @@ -1614,6 +1614,7 @@ void draw_pane_windows(AppSession *session, UiState *state) { void draw_workspace(AppSession *session, const UiMetrics &ui, UiState *state) { state->custom_series.selected = false; state->logs.selected = false; + state->init_data_selected = false; ImGui::SetNextWindowPos(ImVec2(ui.content_x, ui.content_y)); ImGui::SetNextWindowSize(ImVec2(ui.content_w, ui.content_h)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); @@ -1711,6 +1712,11 @@ void draw_workspace(AppSession *session, const UiMetrics &ui, UiState *state) { draw_logs_tab(session, state); ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("initData##workspace_init_data")) { + state->init_data_selected = true; + draw_init_data_tab(session, state); + ImGui::EndTabItem(); + } if (custom_series_tab_open) { ImGuiTabItemFlags custom_flags = ImGuiTabItemFlags_None; if (state->custom_series.request_select) { diff --git a/openpilot/tools/jotpluggler/app.h b/openpilot/tools/jotpluggler/app.h index 9a6777ee90..964370023a 100644 --- a/openpilot/tools/jotpluggler/app.h +++ b/openpilot/tools/jotpluggler/app.h @@ -316,7 +316,19 @@ struct RouteIdentifier { } }; +struct InitDataSection { + std::string name; + std::vector> values; +}; + +struct InitDataSnapshot { + std::vector sections; +}; + +InitDataSnapshot extract_init_data(cereal::InitData::Reader reader); + struct RouteData { + std::optional init_data; std::vector series; std::vector paths; std::vector roots; @@ -340,6 +352,7 @@ struct RouteData { }; struct StreamExtractBatch { + std::optional init_data; std::vector series; std::vector can_messages; std::vector logs; @@ -706,6 +719,8 @@ struct UiState { DbcEditorState dbc_editor; CustomSeriesEditorState custom_series; LogsUiState logs; + bool init_data_selected = false; + std::string init_data_search; UndoStack undo; }; @@ -767,6 +782,7 @@ void draw_custom_series_editor(AppSession *session, UiState *state); // ***** void draw_logs_tab(AppSession *session, UiState *state); +void draw_init_data_tab(AppSession *session, UiState *state); // ***** // map diff --git a/openpilot/tools/jotpluggler/init_data.cc b/openpilot/tools/jotpluggler/init_data.cc new file mode 100644 index 0000000000..d4d2e8ed05 --- /dev/null +++ b/openpilot/tools/jotpluggler/init_data.cc @@ -0,0 +1,113 @@ +#include "tools/jotpluggler/app.h" + +#include + +namespace { + +std::string format_data(capnp::Data::Reader data) { + const bool text = std::all_of(data.begin(), data.end(), [](uint8_t c) { + return (c >= 32 && c < 127) || c == '\n' || c == '\r' || c == '\t'; + }); + if (data.size() == 0) return ""; + if (text) return std::string(reinterpret_cast(data.begin()), data.size()); + constexpr char hex[] = "0123456789abcdef"; + std::string value = "hex: "; + for (uint8_t c : data) { + value += hex[c >> 4]; + value += hex[c & 15]; + } + return value; +} + +std::string format_value(capnp::DynamicValue::Reader value) { + if (value.getType() == capnp::DynamicValue::DATA) return format_data(value.as()); + if (value.getType() == capnp::DynamicValue::TEXT) { + const auto text = value.as(); + return std::string(text.begin(), text.size()); + } + return kj::str(value).cStr(); +} + +} // namespace + +InitDataSnapshot extract_init_data(cereal::InitData::Reader reader) { + InitDataSnapshot snapshot; + InitDataSection metadata{"Device & software", {}}; + auto dynamic = capnp::toDynamic(reader); + for (auto field : dynamic.getSchema().getFields()) { + const std::string name = field.getProto().getName().cStr(); + if (name == "deprecated") continue; + if (name == "params" || name == "commands") { + InitDataSection section{name, {}}; + auto entries = dynamic.get(field).as().get("entries").as(); + for (auto entry : entries) { + auto item = entry.as(); + section.values.emplace_back(format_value(item.get("key")), format_value(item.get("value"))); + } + std::sort(section.values.begin(), section.values.end()); + snapshot.sections.push_back(std::move(section)); + } else { + metadata.values.emplace_back(name, format_value(dynamic.get(field))); + } + } + snapshot.sections.insert(snapshot.sections.begin(), std::move(metadata)); + return snapshot; +} + +void draw_init_data_tab(AppSession *session, UiState *state) { + ImGui::SetNextItemWidth(280.0f); + input_text_with_hint_string("##init_data_search", "Search fields and values...", &state->init_data_search); + ImGui::SameLine(); + ImGui::TextDisabled("Right-click a value to copy"); + ImGui::Separator(); + if (!session->route_data.init_data) { + const auto load = session->route_loader ? session->route_loader->snapshot() : RouteLoadSnapshot{}; + ImGui::TextWrapped("%s", load.active ? "Loading initData..." : "No initData available for this route or stream."); + return; + } + const std::string query = lowercase_copy(state->init_data_search); + const auto matches = [&](const auto &row) { + return query.empty() || lowercase_copy(row.first).find(query) != std::string::npos + || lowercase_copy(row.second).find(query) != std::string::npos; + }; + bool found = false; + if (ImGui::BeginChild("##init_data_content", ImVec2(0, 0), false)) { + for (const auto §ion : session->route_data.init_data->sections) { + const size_t count = std::count_if(section.values.begin(), section.values.end(), matches); + if (!query.empty() && count == 0) continue; + found = found || count > 0; + ImGui::PushID(section.name.c_str()); + const std::string label = section.name + " (" + std::to_string(count) + ")"; + if (ImGui::CollapsingHeader(label.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) { + if (section.values.empty()) ImGui::TextDisabled("No entries"); + if (ImGui::BeginTable("##values", 2, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_Resizable)) { + ImGui::TableSetupColumn("Field", ImGuiTableColumnFlags_WidthFixed, 230.0f); + ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + for (size_t i = 0; i < section.values.size(); ++i) { + const auto &[key, value] = section.values[i]; + if (!matches(section.values[i])) continue; + ImGui::PushID(static_cast(i)); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(key.c_str()); + ImGui::TableSetColumnIndex(1); + app_push_mono_font(); + ImGui::TextWrapped("%s", value.empty() ? "(empty)" : value.c_str()); + app_pop_mono_font(); + if (ImGui::BeginPopupContextItem("##copy")) { + if (ImGui::MenuItem("Copy value")) ImGui::SetClipboardText(value.c_str()); + if (ImGui::MenuItem("Copy field")) ImGui::SetClipboardText(key.c_str()); + ImGui::EndPopup(); + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + } + ImGui::PopID(); + } + if (!query.empty() && !found) ImGui::TextDisabled("No matching fields or values."); + } + ImGui::EndChild(); +} diff --git a/openpilot/tools/jotpluggler/render.cc b/openpilot/tools/jotpluggler/render.cc index 54f0c16cc3..f7d991d2f9 100644 --- a/openpilot/tools/jotpluggler/render.cc +++ b/openpilot/tools/jotpluggler/render.cc @@ -83,7 +83,7 @@ void render_layout(AppSession *session, UiState *state, bool show_camera_feed) { draw_sidebar(session, ui, state, show_camera_feed); draw_workspace(session, ui, state); draw_sidebar_resizer(ui, state); - if (!state->custom_series.selected && !state->logs.selected) { + if (!state->custom_series.selected && !state->logs.selected && !state->init_data_selected) { draw_pane_windows(session, state); } draw_status_bar(*session, ui, state); diff --git a/openpilot/tools/jotpluggler/runtime.cc b/openpilot/tools/jotpluggler/runtime.cc index d7d0fc79c4..dfbb5474c9 100644 --- a/openpilot/tools/jotpluggler/runtime.cc +++ b/openpilot/tools/jotpluggler/runtime.cc @@ -48,7 +48,8 @@ CameraType decoder_camera_type(CameraViewKind view) { } bool stream_batch_has_data(const StreamExtractBatch &batch) { - return !batch.series.empty() + return batch.init_data.has_value() + || !batch.series.empty() || !batch.can_messages.empty() || !batch.logs.empty() || !batch.timeline.empty() @@ -526,6 +527,7 @@ struct StreamPoller::Impl { } merge_can_message_data(&dst->can_messages[it->second], &message); } + if (src->init_data) dst->init_data = std::move(src->init_data); if (!src->logs.empty()) { dst->logs.insert(dst->logs.end(), std::make_move_iterator(src->logs.begin()), diff --git a/openpilot/tools/jotpluggler/sketch_layout.cc b/openpilot/tools/jotpluggler/sketch_layout.cc index 5440d9dd8d..0c84514aa2 100644 --- a/openpilot/tools/jotpluggler/sketch_layout.cc +++ b/openpilot/tools/jotpluggler/sketch_layout.cc @@ -94,6 +94,7 @@ void decode_can_frame(const dbc::Database *can_dbc, #include "tools/jotpluggler/generated_event_extractors.h" struct LoadedRouteArtifacts { + std::optional init_data; std::vector series; std::vector can_messages; std::vector logs; @@ -1560,6 +1561,7 @@ LoadedRouteArtifacts load_route_series_parallel( LoadStats *stats) { struct SegmentResult { SeriesAccumulator series; + std::optional init_data; std::vector logs; std::vector timeline; std::vector thumbnails; @@ -1624,6 +1626,13 @@ LoadedRouteArtifacts load_route_series_parallel( const auto extract_start = LoadStats::Clock::now(); 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); + for (const Event &record : reader.events) { + if (record.which != cereal::Event::Which::INIT_DATA) continue; + with_parseable_event(record.data, [&](const cereal::Event::Reader &event) { + results[index].init_data = extract_init_data(event.getInitData()); + }); + if (results[index].init_data) break; + } 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(); @@ -1671,6 +1680,12 @@ LoadedRouteArtifacts load_route_series_parallel( } } LoadedRouteArtifacts artifacts; + for (SegmentResult &result : results) { + if (result.init_data) { + artifacts.init_data = std::move(result.init_data); + break; + } + } artifacts.series = collect_series(std::move(merged)); artifacts.can_messages = std::move(merged.can_messages); artifacts.logs = std::move(logs); @@ -1720,6 +1735,7 @@ std::vector collect_route_roots_for_paths(const std::vector init_data; const SchemaIndex &schema = SchemaIndex::instance(); SeriesAccumulator series = make_series_accumulator(schema); std::vector logs; @@ -1762,6 +1778,9 @@ void StreamAccumulator::appendEvent(kj::ArrayPtr data) { if (!impl_->time_offset.has_value()) { impl_->time_offset = boot_time; } + if (which == cereal::Event::Which::INIT_DATA) { + impl_->init_data = extract_init_data(event.getInitData()); + } if (which == cereal::Event::Which::CAR_PARAMS) { const std::string fingerprint = event.getCarParams().getCarFingerprint().cStr(); if (!fingerprint.empty() && fingerprint != impl_->car_fingerprint) { @@ -1803,6 +1822,8 @@ void StreamAccumulator::appendCanFrames(CanServiceKind service, const std::vecto StreamExtractBatch StreamAccumulator::takeBatch() { StreamExtractBatch batch; + batch.init_data = std::move(impl_->init_data); + impl_->init_data.reset(); batch.car_fingerprint = impl_->car_fingerprint; batch.dbc_name = impl_->detected_dbc_name; if (impl_->time_offset.has_value()) { @@ -1885,6 +1906,7 @@ RouteData load_route_data(const std::string &route_name, std::move(artifacts.enum_info), metadata.car_fingerprint, resolved_dbc); + route_data.init_data = std::move(artifacts.init_data); route_data.route_id = make_route_identifier(route, segments); build_camera_index(segments, route_data, &SegmentLogs::narrow_road, "narrowRoadEncodeIdx", &route_data.road_camera); build_camera_index(segments, route_data, &SegmentLogs::cabin, "cabinEncodeIdx", &route_data.cabin_camera); diff --git a/openpilot/tools/jotpluggler/stream.cc b/openpilot/tools/jotpluggler/stream.cc index fcfa6585bb..d5e8fa43b7 100644 --- a/openpilot/tools/jotpluggler/stream.cc +++ b/openpilot/tools/jotpluggler/stream.cc @@ -72,6 +72,7 @@ bool can_message_less(const CanMessageData &a, const CanMessageData &b) { } void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch batch) { + if (batch.init_data) session->route_data.init_data = std::move(batch.init_data); if (batch.has_time_offset) { session->stream_time_offset = batch.time_offset; } From fc616407826c52abd1338a34e85ce9d4fcbd20e1 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Mon, 5 Oct 2026 18:31:10 -0700 Subject: [PATCH 192/270] jotpluggler: fix subset time bounds (#39041) --- openpilot/tools/jotpluggler/sketch_layout.cc | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openpilot/tools/jotpluggler/sketch_layout.cc b/openpilot/tools/jotpluggler/sketch_layout.cc index 0c84514aa2..1971800c41 100644 --- a/openpilot/tools/jotpluggler/sketch_layout.cc +++ b/openpilot/tools/jotpluggler/sketch_layout.cc @@ -1202,9 +1202,12 @@ RouteData build_route_data(std::vector &&series_list, route_data.paths.reserve(series_list.size()); for (RouteSeries &series : series_list) { if (series.times.empty()) continue; - route_data.has_time_range = true; - route_data.x_min = route_data.series.empty() ? series.times.front() : std::min(route_data.x_min, series.times.front()); - route_data.x_max = route_data.series.empty() ? series.times.back() : std::max(route_data.x_max, series.times.back()); + // initData is copied into every segment with its original route timestamp. + if (series.path.rfind("/initData/", 0) != 0) { + route_data.x_min = !route_data.has_time_range ? series.times.front() : std::min(route_data.x_min, series.times.front()); + route_data.x_max = !route_data.has_time_range ? series.times.back() : std::max(route_data.x_max, series.times.back()); + route_data.has_time_range = true; + } route_data.paths.push_back(series.path); route_data.series.push_back(std::move(series)); } From 2b7c34a5c32f78f4892a07598cbe3827be880438 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Mon, 5 Oct 2026 18:42:04 -0700 Subject: [PATCH 193/270] jp: show text fields (#39042) --- openpilot/tools/jotpluggler/app.h | 11 +++++++++++ openpilot/tools/jotpluggler/browser.cc | 17 +++++++++++++++-- openpilot/tools/jotpluggler/common.cc | 11 +++++++++++ .../jotpluggler/generate_event_extractors.py | 18 +++++++++++++----- openpilot/tools/jotpluggler/plot.cc | 4 ++-- openpilot/tools/jotpluggler/sketch_layout.cc | 18 ++++++++++++++++++ openpilot/tools/jotpluggler/stream.cc | 6 +++++- 7 files changed, 75 insertions(+), 10 deletions(-) diff --git a/openpilot/tools/jotpluggler/app.h b/openpilot/tools/jotpluggler/app.h index 964370023a..e089609247 100644 --- a/openpilot/tools/jotpluggler/app.h +++ b/openpilot/tools/jotpluggler/app.h @@ -168,8 +168,19 @@ struct LogEntry { struct EnumInfo { std::vector names; + bool is_text = false; + std::unordered_map text_indices; + + double text_value(std::string text) { + is_text = true; + auto [it, inserted] = text_indices.try_emplace(text, names.size()); + if (inserted) names.push_back(std::move(text)); + return static_cast(it->second); + } }; +void merge_text_labels(RouteSeries *incoming, const EnumInfo &labels, EnumInfo *destination); + struct SeriesFormat { int decimals = 3; bool integer_like = false; diff --git a/openpilot/tools/jotpluggler/browser.cc b/openpilot/tools/jotpluggler/browser.cc index 91e4a707da..3342e82b37 100644 --- a/openpilot/tools/jotpluggler/browser.cc +++ b/openpilot/tools/jotpluggler/browser.cc @@ -310,8 +310,9 @@ std::string format_display_value(double display_value, const int idx = static_cast(std::llround(display_value)); if (idx >= 0 && std::abs(display_value - static_cast(idx)) < 0.01 && static_cast(idx) < enum_info->names.size() - && !enum_info->names[static_cast(idx)].empty()) { - return enum_info->names[static_cast(idx)]; + && (enum_info->is_text || !enum_info->names[static_cast(idx)].empty())) { + const std::string &name = enum_info->names[static_cast(idx)]; + return name.empty() ? "(empty)" : name; } } char buf[64] = {}; @@ -416,6 +417,18 @@ void draw_browser_node(AppSession *session, app_pop_mono_font(); } + if (hovered) { + auto it = session->route_data.enum_info.find(node.full_path); + if (it != session->route_data.enum_info.end() && it->second.is_text) { + ImGui::BeginTooltip(); + ImGui::TextUnformatted(node.full_path.c_str()); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); + ImGui::TextWrapped("%s", value_text.c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + } + if (clicked) { const bool shift_down = ImGui::GetIO().KeyShift; const bool ctrl_down = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeySuper; diff --git a/openpilot/tools/jotpluggler/common.cc b/openpilot/tools/jotpluggler/common.cc index 50f5fc0b95..4136063607 100644 --- a/openpilot/tools/jotpluggler/common.cc +++ b/openpilot/tools/jotpluggler/common.cc @@ -185,3 +185,14 @@ std::string route_google_maps_url(const GpsTrace &trace) { if (url.size() <= 1900 || n == 0) return url; } } + +void merge_text_labels(RouteSeries *incoming, const EnumInfo &labels, EnumInfo *destination) { + std::vector remap; + remap.reserve(labels.names.size()); + for (const std::string &text : labels.names) { + remap.push_back(destination->text_value(text)); + } + for (double &value : incoming->values) { + value = remap.at(static_cast(value)); + } +} diff --git a/openpilot/tools/jotpluggler/generate_event_extractors.py b/openpilot/tools/jotpluggler/generate_event_extractors.py index a424ebc237..2dc506e4be 100644 --- a/openpilot/tools/jotpluggler/generate_event_extractors.py +++ b/openpilot/tools/jotpluggler/generate_event_extractors.py @@ -18,7 +18,7 @@ SCALAR_KINDS = { "enum": "Enum", } NESTED_TYPE_KINDS = {"struct", "list"} -IGNORED_TYPE_KINDS = {"void", "text", "data", "interface", "anyPointer"} +IGNORED_TYPE_KINDS = {"void", "data", "interface", "anyPointer"} def cxx_string(value): @@ -114,6 +114,11 @@ class Generator: self.emit(indent, f"append_fixed_scalar_point(&series->fixed_series[{slot}], tm, {double_expr});") return + if type_kind == "text": + target = f"ensure_dynamic_series({path_expr}, series)" if dynamic_path else f"&series->fixed_series[{self.add_fixed_path(path)}]" + self.emit(indent, f"append_text_point({target}, tm, {expr}, series);") + return + if type_kind == "struct": self.emit_struct(indent, schema, expr, path, path_expr, dynamic_path) return @@ -180,7 +185,7 @@ class Generator: self.emit(indent, f"const std::string {base_path_var} = {cxx_string(path)};") elem_scalar = scalar_kind(elem_type) - if elem_scalar is not None: + if elem_scalar is not None or elem_kind == "text": self.emit(indent, f"if ({list_expr}.size() <= 16) {{") index_var = self.tmp("i") self.emit(indent + 2, f"for (uint {index_var} = 0; {index_var} < {list_expr}.size(); ++{index_var}) {{") @@ -188,7 +193,10 @@ class Generator: self.emit(indent + 4, f"RouteSeries *{item_series} = ensure_list_scalar_series({base_path_var}, {index_var}, series);") if elem_scalar == "Enum": self.emit_enum_capture(indent + 4, f"{item_series}->path", enum_names(schema.elementType)) - self.emit(indent + 4, f"append_fixed_scalar_point({item_series}, tm, {self.scalar_double_expr(f'{list_expr}[{index_var}]', elem_scalar)});") + if elem_kind == "text": + self.emit(indent + 4, f"append_text_point({item_series}, tm, {list_expr}[{index_var}], series);") + else: + self.emit(indent + 4, f"append_fixed_scalar_point({item_series}, tm, {self.scalar_double_expr(f'{list_expr}[{index_var}]', elem_scalar)});") self.emit(indent + 2, "}") self.emit(indent, "}") return @@ -207,7 +215,7 @@ class Generator: self.emit(indent, "}") def node_emits(self, type_kind, type_proto, schema, seen=frozenset()): - if scalar_kind(type_proto) is not None: + if scalar_kind(type_proto) is not None or type_kind == "text": return True if type_kind == "struct": if schema is None: @@ -238,7 +246,7 @@ class Generator: elem_kind = elem_type.which() if elem_kind in IGNORED_TYPE_KINDS: return False - if scalar_kind(elem_type) is not None: + if scalar_kind(elem_type) is not None or elem_kind == "text": return True if elem_kind == "struct": return self.node_emits("struct", None, schema.elementType, seen) diff --git a/openpilot/tools/jotpluggler/plot.cc b/openpilot/tools/jotpluggler/plot.cc index 019eff7070..b8697c6872 100644 --- a/openpilot/tools/jotpluggler/plot.cc +++ b/openpilot/tools/jotpluggler/plot.cc @@ -117,8 +117,8 @@ ImU32 state_block_color(int value, float alpha = 1.0f) { std::string state_block_label(const PreparedCurve &curve, int value) { if (curve.enum_info != nullptr && value >= 0 && static_cast(value) < curve.enum_info->names.size()) { const std::string &name = curve.enum_info->names[static_cast(value)]; - if (!name.empty()) { - return name; + if (!name.empty() || curve.enum_info->is_text) { + return name.empty() ? "(empty)" : name; } } return std::to_string(value); diff --git a/openpilot/tools/jotpluggler/sketch_layout.cc b/openpilot/tools/jotpluggler/sketch_layout.cc index 1971800c41..8fb634f7fa 100644 --- a/openpilot/tools/jotpluggler/sketch_layout.cc +++ b/openpilot/tools/jotpluggler/sketch_layout.cc @@ -74,6 +74,8 @@ struct SeriesAccumulator { void append_fixed_scalar_point(RouteSeries *series, double tm, double value); void append_dynamic_scalar_point(const std::string &path, double tm, double value, SeriesAccumulator *series); +RouteSeries *ensure_dynamic_series(const std::string &path, SeriesAccumulator *series); +void append_text_point(RouteSeries *series, double tm, capnp::Text::Reader text, SeriesAccumulator *accumulator); RouteSeries *ensure_list_scalar_series(const std::string &base_path, size_t index, SeriesAccumulator *series); void append_can_frame(CanServiceKind service, uint8_t bus, @@ -959,7 +961,14 @@ void append_can_frame(CanServiceKind service, }); } +void append_text_point(RouteSeries *series, double tm, capnp::Text::Reader text, SeriesAccumulator *accumulator) { + const double value = accumulator->enum_info[series->path].text_value(std::string(text.begin(), text.size())); + append_fixed_scalar_point(series, tm, value); +} + void append_dynamic_scalar_point(const std::string &path, double tm, double value, SeriesAccumulator *series); +RouteSeries *ensure_dynamic_series(const std::string &path, SeriesAccumulator *series); +void append_text_point(RouteSeries *series, double tm, capnp::Text::Reader text, SeriesAccumulator *accumulator); void decode_can_frame(const dbc::Database *can_dbc, const std::string &service_name, @@ -1116,6 +1125,15 @@ void merge_series_accumulator(SeriesAccumulator *dst, SeriesAccumulator *src) { throw std::runtime_error("Fixed-series slot count mismatch during merge"); } + const auto remap_text = [&](RouteSeries &series) { + auto it = src->enum_info.find(series.path); + if (it != src->enum_info.end() && it->second.is_text) { + merge_text_labels(&series, it->second, &dst->enum_info[series.path]); + } + }; + for (RouteSeries &series : src->fixed_series) remap_text(series); + for (RouteSeries &series : src->dynamic_series) remap_text(series); + for (size_t i = 0; i < dst->fixed_series.size(); ++i) { merge_route_series(&dst->fixed_series[i], &src->fixed_series[i]); } diff --git a/openpilot/tools/jotpluggler/stream.cc b/openpilot/tools/jotpluggler/stream.cc index d5e8fa43b7..35bca3d3c5 100644 --- a/openpilot/tools/jotpluggler/stream.cc +++ b/openpilot/tools/jotpluggler/stream.cc @@ -84,7 +84,7 @@ void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch } if (!batch.enum_info.empty()) { for (auto &[path, info] : batch.enum_info) { - session->route_data.enum_info[path] = std::move(info); + if (!info.is_text) session->route_data.enum_info[path] = std::move(info); } } @@ -93,6 +93,10 @@ void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch std::vector touched_paths; touched_paths.reserve(batch.series.size()); for (RouteSeries &incoming : batch.series) { + auto labels = batch.enum_info.find(incoming.path); + if (labels != batch.enum_info.end() && labels->second.is_text) { + merge_text_labels(&incoming, labels->second, &session->route_data.enum_info[incoming.path]); + } touched_paths.push_back(incoming.path); auto existing_it = session->series_by_path.find(incoming.path); if (existing_it == session->series_by_path.end()) { From c933446486e6923c8468a979b6fab890b46f5cdb Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Mon, 5 Oct 2026 19:52:56 -0700 Subject: [PATCH 194/270] jp: fix file resolving --- openpilot/tools/jotpluggler/sketch_layout.cc | 16 ++- openpilot/tools/lib/file_downloader.py | 104 +++++++++++++++++++ openpilot/tools/replay/py_downloader.cc | 5 + openpilot/tools/replay/py_downloader.h | 3 + 4 files changed, 119 insertions(+), 9 deletions(-) diff --git a/openpilot/tools/jotpluggler/sketch_layout.cc b/openpilot/tools/jotpluggler/sketch_layout.cc index 8fb634f7fa..bca20ec20c 100644 --- a/openpilot/tools/jotpluggler/sketch_layout.cc +++ b/openpilot/tools/jotpluggler/sketch_layout.cc @@ -320,20 +320,18 @@ std::map trim_segments(std::map segments, co std::map load_segments_from_json(const json11::Json &json) { std::map segments; - static const std::regex rx(R"(\/(\d+)\/)"); - for (const auto &value : json.object_items()) { - for (const auto &url : value.second.array_items()) { - const std::string url_str = url.string_value(); - std::smatch match; - if (!std::regex_search(url_str, match, rx)) continue; - add_log_file_to_segments(&segments, std::stoi(match[1].str()), url_str); - } + for (const auto &[number, files] : json.object_items()) { + segments[std::stoi(number)] = { + files["rlog"].string_value(), files["qlog"].string_value(), files["narrow_road"].string_value(), + files["cabin"].string_value(), files["wide_road"].string_value(), files["qcamera"].string_value(), + }; } return segments; } std::map load_segments_from_server(const RouteSelection &route) { - const std::string result = PyDownloader::getRouteFiles(route.canonical_name); + const std::string selector = route.selector == LogSelector::RLog ? "r" : route.selector == LogSelector::QLog ? "q" : "a"; + const std::string result = PyDownloader::resolveRouteFiles(route.canonical_name, route.begin_segment, route.end_segment, selector); if (result.empty()) throw std::runtime_error("Failed to fetch route files for " + route.canonical_name); std::string parse_error; diff --git a/openpilot/tools/lib/file_downloader.py b/openpilot/tools/lib/file_downloader.py index fd517d7ada..b473740be9 100755 --- a/openpilot/tools/lib/file_downloader.py +++ b/openpilot/tools/lib/file_downloader.py @@ -5,6 +5,7 @@ Called by C++ replay/cabana via subprocess. Subcommands: route-files - Get route file URLs as JSON + resolve-route-files - Resolve logs and cameras from API and internal storage download - Download/decompress URL to local cache, print local path decompress - Decompress a local log file, print temporary path devices - List user's devices as JSON @@ -18,6 +19,8 @@ import os import shutil import sys import tempfile +import traceback +from urllib.parse import urlparse from openpilot.common.hardware.hw import Paths @@ -100,6 +103,100 @@ def cmd_route_files(args): api_call(lambda api: api.get(f"v1/route/{args.route}/files")) +def resolve_route_files(route_name: str, begin: int = 0, end: int = -1, selector: str = "a") -> dict[int, dict[str, str]]: + from openpilot.tools.lib.filereader import DATA_ENDPOINT + from openpilot.tools.lib.file_sources import InternalUnavailableException, internal_source + from openpilot.tools.lib.route import FileName, SegmentRange + from openpilot.tools.lib.api import CommaApi + from openpilot.tools.lib.auth_config import get_token + + sr = SegmentRange(route_name) + api_error = None + try: + route_files = CommaApi(get_token()).get(f"v1/route/{sr.route_name}/files") + except Exception as e: + api_error = e + route_files = {} + fields = {"rlog": FileName.RLOG, "qlog": FileName.QLOG, + "narrow_road": FileName.FCAMERA, "cabin": FileName.DCAMERA, "wide_road": FileName.ECAMERA, "qcamera": FileName.QCAMERA} + names = {name: key for key, fns in fields.items() for name in fns} + names.update({"rlog": "rlog", "qlog": "qlog"}) + api_files: dict[int, dict[str, str]] = {} + for url in (url for urls in route_files.values() for url in urls): + _, number, name = urlparse(url).path.rsplit("/", 2) + if name in names: + api_files.setdefault(int(number), {})[names[name]] = url + # Preserve replay's inclusive segment bounds. + open_ended = end < 0 + local_internal = os.path.isdir(DATA_ENDPOINT) + if open_ended: + end = max(begin, max(api_files, default=0)) + if local_internal: + internal_dir = os.path.join(DATA_ENDPOINT, sr.dongle_id, sr.log_id) + if os.path.isdir(internal_dir): + end = max(end, max((int(name) for name in os.listdir(internal_dir) if name.isdigit()), default=0)) + indices = list(range(max(0, begin), end + 1)) + segments: dict[int, dict[str, str]] = {seg: {} for seg in indices} + internal_errors: dict[str, Exception] = {} + + def resolve(key, requested): + files = {seg: api_files[seg][key] for seg in requested if key in api_files.get(seg, {})} + missing = [seg for seg in requested if seg not in files] + if missing: + try: + files.update(internal_source(sr, missing, fields[key])) + except Exception as e: + if e is not InternalUnavailableException: + internal_errors[key] = e + for seg, path in files.items(): + segments[seg][key] = path + + log_types = ["qlog"] if selector == "q" else ["rlog"] + if selector == "a": + log_types.append("qlog") + for key in log_types: + resolve(key, [seg for seg in indices if not segments[seg]]) + if open_ended and not local_internal: + # Remote internal storage exposes individual files, not a segment listing. + # Probe the contiguous tail beyond the API's last segment until no selected log exists. + number = end + 1 + while True: + internal_errors.clear() + segments[number] = {} + for key in log_types: + if not segments[number]: + resolve(key, [number]) + if not segments[number]: + if internal_errors: + print(f"Internal discovery stopped at segment {number}; keeping resolved logs", file=sys.stderr) + traceback.print_exception(next(iter(internal_errors.values()))) + del segments[number] + break + indices.append(number) + number += 1 + available = [seg for seg in indices if segments[seg]] + if not available: + if api_error is not None: + raise api_error + raise FileNotFoundError(f"No {selector} logs found for {sr.route_name} in segments {indices} through the API or internal storage") + for key in ("narrow_road", "cabin", "wide_road", "qcamera"): + resolve(key, available) + return {seg: segments[seg] for seg in available} + + +def cmd_resolve_route_files(args): + try: + result = resolve_route_files(args.route, args.begin, args.end, args.selector) + except Exception as e: + error = f"{type(e).__name__} resolving {args.route} (begin={args.begin}, end={args.end}, selector={args.selector}): {e}" + print(error, file=sys.stderr) + traceback.print_exc() + result = {"error": error} + json.dump(result, sys.stdout) + sys.stdout.write("\n") + sys.stdout.flush() + + def cmd_download(args): url = args.url use_cache = not args.no_cache @@ -232,6 +329,13 @@ def main(): p_rf.add_argument("route") p_rf.set_defaults(func=cmd_route_files) + p_resolve = subparsers.add_parser("resolve-route-files") + p_resolve.add_argument("route") + p_resolve.add_argument("--begin", type=int, default=0) + p_resolve.add_argument("--end", type=int, default=-1, help="Inclusive last segment; negative means open-ended") + p_resolve.add_argument("--selector", choices=("a", "r", "q"), default="a") + p_resolve.set_defaults(func=cmd_resolve_route_files) + p_dl = subparsers.add_parser("download") p_dl.add_argument("url") p_dl.add_argument("--no-cache", action="store_true") diff --git a/openpilot/tools/replay/py_downloader.cc b/openpilot/tools/replay/py_downloader.cc index 286d8a7319..9d4ed04122 100644 --- a/openpilot/tools/replay/py_downloader.cc +++ b/openpilot/tools/replay/py_downloader.cc @@ -229,6 +229,11 @@ std::string getRouteFiles(const std::string &route) { return runPython(DOWNLOADER_MODULE, {"route-files", route}); } +std::string resolveRouteFiles(const std::string &route, int begin, int end, const std::string &selector) { + return runPython(DOWNLOADER_MODULE, {"resolve-route-files", route, "--begin", std::to_string(begin), + "--end", std::to_string(end), "--selector", selector}); +} + std::string authenticate(const std::string &provider, std::atomic *abort) { return runPython(AUTH_MODULE, {provider, "--json"}, abort); } diff --git a/openpilot/tools/replay/py_downloader.h b/openpilot/tools/replay/py_downloader.h index fac66dabd5..92bc62140b 100644 --- a/openpilot/tools/replay/py_downloader.h +++ b/openpilot/tools/replay/py_downloader.h @@ -18,6 +18,9 @@ std::string decompress(const std::string &path, std::atomic *abort = nullp // Returns JSON string of route files (same format as /v1/route/.../files API) std::string getRouteFiles(const std::string &route); +// Resolves API/internal files into a JSON manifest keyed by segment number. End is inclusive. +std::string resolveRouteFiles(const std::string &route, int begin, int end, const std::string &selector); + // Browser sign-in; abort closes the local callback server. Returns a JSON status. std::string authenticate(const std::string &provider, std::atomic *abort); From ab8c84c3989bab34969d83e78c5fad3ee5b95112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Tue, 6 Oct 2026 12:01:13 -0700 Subject: [PATCH 195/270] Gas override boost (#39015) * Add gas override boost, mici boost bar, and Rivian maximum set speed * Refactor acceleration boost and gate accumulation on model limit * Fade acceleration boost with model acceleration * Limit boost buildup per gas override * Latch boost eligibility before gas override * Move model limiting check into AccelBoost * Use DT_MDL directly in AccelBoost * Clarify acceleration boost state names * Scale acceleration boost by speed instead of decaying it * Enable Experimental Mode by default * Scale boost buildup with the application speed ramp * Fix boost serialization and model-limit eligibility * Remove boost UI and Experimental Mode default changes * Combine boost update and application --- openpilot/cereal/log.capnp | 1 + .../selfdrive/controls/lib/accel_boost.py | 39 +++++++++++++++++++ .../controls/lib/longitudinal_planner.py | 5 +++ 3 files changed, 45 insertions(+) create mode 100644 openpilot/selfdrive/controls/lib/accel_boost.py diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 48bbdb9797..82d4b1ba73 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -1240,6 +1240,7 @@ struct LongitudinalPlan @0xe00b5b3eba12876c { shouldStop @37: Bool; allowThrottle @38: Bool; allowBrake @39: Bool; + accelBoost @40 :Float32; solverExecutionTime @35 :Float32; diff --git a/openpilot/selfdrive/controls/lib/accel_boost.py b/openpilot/selfdrive/controls/lib/accel_boost.py new file mode 100644 index 0000000000..6059f8dabc --- /dev/null +++ b/openpilot/selfdrive/controls/lib/accel_boost.py @@ -0,0 +1,39 @@ +import numpy as np +from openpilot.common.constants import CV +from openpilot.common.realtime import DT_MDL + +ACCEL_BOOST_MAX = 0.2 +ACCEL_BOOST_RATE = 0.025 +ACCEL_BOOST_PER_OVERRIDE = 0.05 +ACCEL_BOOST_MIN_SPEED = 10 * CV.MPH_TO_MS + + +class AccelBoost: + def __init__(self): + self.total_boost = 0.0 + self.boost_this_override = 0.0 + self.boost_eligible = False + + def update(self, sm, output_a_target_e2e, output_a_target_mpc, a_cruise): + enabled = sm['selfdriveState'].enabled + gas_pressed = sm['carState'].gasPressed + v_ego = sm['carState'].vEgo + speed_scale = np.interp(v_ego, [ACCEL_BOOST_MIN_SPEED, 2 * ACCEL_BOOST_MIN_SPEED], [0.0, 1.0]) + boost_scale = speed_scale * np.interp(output_a_target_e2e, [-1.0, -0.5, 5.0], [0.0, 1.0, 1.0], right=0.0) + model_limited = (sm['selfdriveState'].experimentalMode and + output_a_target_e2e + self.total_boost * boost_scale < min(output_a_target_mpc, a_cruise) - 0.1) + + if not enabled or not gas_pressed: + self.boost_this_override = 0.0 + # Latch eligibility before the override changes the lead plan. + self.boost_eligible = enabled and model_limited + + if not enabled: + self.total_boost = 0.0 + elif gas_pressed and self.boost_eligible: + increase = min(ACCEL_BOOST_RATE * DT_MDL * speed_scale, + ACCEL_BOOST_PER_OVERRIDE - self.boost_this_override, ACCEL_BOOST_MAX - self.total_boost) + self.total_boost += increase + self.boost_this_override += increase + + return output_a_target_e2e + self.total_boost * boost_scale diff --git a/openpilot/selfdrive/controls/lib/longitudinal_planner.py b/openpilot/selfdrive/controls/lib/longitudinal_planner.py index cc1345a6ae..d453b677f8 100755 --- a/openpilot/selfdrive/controls/lib/longitudinal_planner.py +++ b/openpilot/selfdrive/controls/lib/longitudinal_planner.py @@ -8,6 +8,7 @@ from openpilot.common.constants import CV from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.modeld.constants import ModelConstants +from openpilot.selfdrive.controls.lib.accel_boost import AccelBoost from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalMpc, LongitudinalPlanSource from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import T_IDXS as T_IDXS_MPC @@ -60,6 +61,7 @@ class LongitudinalPlanner: self.fcw = False self.dt = dt self.allow_throttle = True + self.accel_boost = AccelBoost() self.v_desired_filter = FirstOrderFilter(init_v, 2.0, self.dt) self.a_cruise = init_a @@ -135,6 +137,8 @@ class LongitudinalPlanner: accel_coast, self.allow_throttle) cruise_should_stop = should_stop(v_ego, self.a_cruise) + output_a_target_e2e = self.accel_boost.update(sm, output_a_target_e2e, output_a_target_mpc, self.a_cruise) + candidates = [(output_a_target_mpc, self.mpc.source, output_should_stop_mpc), (self.a_cruise, LongitudinalPlanSource.cruise, cruise_should_stop)] if sm['selfdriveState'].experimentalMode: @@ -165,6 +169,7 @@ class LongitudinalPlanner: longitudinalPlan.fcw = self.fcw longitudinalPlan.aTarget = float(self.output_a_target) + longitudinalPlan.accelBoost = float(self.accel_boost.total_boost) longitudinalPlan.shouldStop = bool(self.output_should_stop) longitudinalPlan.allowBrake = True longitudinalPlan.allowThrottle = bool(self.allow_throttle) From 5b2a59d288f9d1eb3123f7af81ffab4159600b1f Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:42:39 -0700 Subject: [PATCH 196/270] ui: bookmark fill (#38631) * bookmark fill * fade and sticky click * fade out animation * no funny animation * clean --------- Co-authored-by: Daniel Koepping --- .../assets/icons_mici/onroad/bookmark.png | 4 +- .../icons_mici/onroad/bookmark_fill.png | 3 ++ .../ui/mici/onroad/augmented_road_view.py | 51 +++++++++++++------ 3 files changed, 41 insertions(+), 17 deletions(-) create mode 100644 openpilot/selfdrive/assets/icons_mici/onroad/bookmark_fill.png diff --git a/openpilot/selfdrive/assets/icons_mici/onroad/bookmark.png b/openpilot/selfdrive/assets/icons_mici/onroad/bookmark.png index 305561f509..75a19e7983 100644 --- a/openpilot/selfdrive/assets/icons_mici/onroad/bookmark.png +++ b/openpilot/selfdrive/assets/icons_mici/onroad/bookmark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fd91685bf656e828648acf035a4737acb2c4709e8514cf0aa0a10fa470a9bb60 -size 11580 +oid sha256:92431d37a844206570d73d48f39dd8d934539a89668fe8d5f4cccae38f7b301b +size 11530 diff --git a/openpilot/selfdrive/assets/icons_mici/onroad/bookmark_fill.png b/openpilot/selfdrive/assets/icons_mici/onroad/bookmark_fill.png new file mode 100644 index 0000000000..94cc355358 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/onroad/bookmark_fill.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3efe7d12ae82e0f5a1bd773129cf1eafe3c8c36ff5cba43a6c95f295706062b5 +size 19724 diff --git a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py index 3430d72f61..4b19f0db5f 100644 --- a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -14,7 +14,7 @@ from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent, TextAlignment, TextAlignmentVertical from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets import Widget -from openpilot.common.filter_simple import BounceFilter +from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame from openpilot.common.transformations.orientation import rot_from_euler from enum import IntEnum @@ -38,7 +38,7 @@ CAM_Y_OFFSET = 20 class BookmarkIcon(Widget): - PEEK_THRESHOLD = 50 # If icon peeks out this much, snap it fully visible + PEEK_THRESHOLD = 150 # If icon peeks out this much, snap it fully visible FULL_VISIBLE_OFFSET = 200 # How far onscreen when fully visible HIDDEN_OFFSET = -50 # How far offscreen when hidden @@ -46,7 +46,9 @@ class BookmarkIcon(Widget): super().__init__() self._bookmark_callback = bookmark_callback self._icon = gui_app.texture("icons_mici/onroad/bookmark.png", 180, 180) + self._filled_icon = gui_app.texture("icons_mici/onroad/bookmark_fill.png", 180, 180) self._offset_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps) + self._active_alpha = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) # State self._interacting = False @@ -56,6 +58,8 @@ class BookmarkIcon(Widget): self._is_swiping = False self._is_swiping_left: bool = False self._triggered_time: float = 0.0 + self._triggered_duration = 1.5 + self._swipe_expired = False def is_swiping_left(self) -> bool: """Check if currently swiping left (for scroller to disable).""" @@ -66,18 +70,25 @@ class BookmarkIcon(Widget): return interacting def _update_state(self): + if self._state == BookmarkState.TRIGGERED and rl.get_time() - self._triggered_time >= self._triggered_duration: + self._state = BookmarkState.HIDDEN + self._swipe_expired = self._is_swiping + + swipe_offset = self._swipe_start_x - self._swipe_current_x + armed = self._state == BookmarkState.DRAGGING and swipe_offset > self.PEEK_THRESHOLD if self._state == BookmarkState.DRAGGING: - # Allow pulling past activated position with rubber band effect - swipe_offset = self._swipe_start_x - self._swipe_current_x + # Snap to the released position when armed, while allowing further dragging or cancellation. + if armed: + swipe_offset += self.FULL_VISIBLE_OFFSET - self.PEEK_THRESHOLD swipe_offset = min(swipe_offset, self.FULL_VISIBLE_OFFSET + 50) self._offset_filter.update(swipe_offset) elif self._state == BookmarkState.TRIGGERED: - # Continue animating to fully visible - self._offset_filter.update(self.FULL_VISIBLE_OFFSET) - # Stay in TRIGGERED state for 1 second - if rl.get_time() - self._triggered_time >= 1.5: - self._state = BookmarkState.HIDDEN + # Let another left swipe move the same bookmark. + offset = self.FULL_VISIBLE_OFFSET + if self._is_swiping and self._is_swiping_left: + offset += min(swipe_offset, 50) + self._offset_filter.update(offset) elif self._state == BookmarkState.HIDDEN: self._offset_filter.update(self.HIDDEN_OFFSET) @@ -85,17 +96,23 @@ class BookmarkIcon(Widget): if self._offset_filter.x < 1e-3: self._interacting = False + self._active_alpha.update(float(armed or self._state == BookmarkState.TRIGGERED)) + def _handle_mouse_event(self, mouse_event: MouseEvent): if not ui_state.started: return if mouse_event.left_pressed: + if self._state == BookmarkState.TRIGGERED: + self._triggered_time = rl.get_time() + self._triggered_duration = 0.5 + # Store relative position within widget self._swipe_start_x = mouse_event.pos.x self._swipe_current_x = mouse_event.pos.x self._is_swiping = True self._is_swiping_left = False - self._state = BookmarkState.DRAGGING + self._swipe_expired = False elif mouse_event.left_down and self._is_swiping: self._swipe_current_x = mouse_event.pos.x @@ -103,30 +120,34 @@ class BookmarkIcon(Widget): self._is_swiping_left = swipe_offset > 0 if self._is_swiping_left: self._interacting = True + if self._state == BookmarkState.HIDDEN and not self._swipe_expired: + self._state = BookmarkState.DRAGGING elif mouse_event.left_released: - if self._is_swiping: + if self._is_swiping and self._state == BookmarkState.DRAGGING: swipe_distance = self._swipe_start_x - self._swipe_current_x # If peeking past threshold, transition to animating to fully visible and bookmark if swipe_distance > self.PEEK_THRESHOLD: self._state = BookmarkState.TRIGGERED self._triggered_time = rl.get_time() + self._triggered_duration = 1.5 self._bookmark_callback() else: # Otherwise, transition back to hidden self._state = BookmarkState.HIDDEN - # Reset swipe state - self._is_swiping = False - self._is_swiping_left = False + # Reset swipe state + self._is_swiping = False + self._is_swiping_left = False def _render(self, _): """Render the bookmark icon.""" if self._offset_filter.x > 0: icon_x = self.rect.x + self.rect.width - round(self._offset_filter.x) icon_y = self.rect.y + (self.rect.height - self._icon.height) / 2 # Vertically centered - rl.draw_texture_ex(self._icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) + for icon, alpha in ((self._icon, 1.0 - self._active_alpha.x), (self._filled_icon, self._active_alpha.x)): + rl.draw_texture_ex(icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.Color(255, 255, 255, round(255 * alpha))) class AugmentedRoadView(CameraView): From 035a447d1f0f0ef5abf2ed9724e33d9265399019 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 6 Oct 2026 15:51:25 -0700 Subject: [PATCH 197/270] ui: longitudinal policy indicator (#39040) * ui: mici longitudinal icons * ui: rm orange state * ui: lead policy indicator * fix * show on op long * make own widget * sync long indicator with dmoji * fast fade in with dmoji --- .../assets/icons_mici/longitudinal/car.png | 3 + .../icons_mici/longitudinal/car_green.png | 3 + .../icons_mici/longitudinal/distance_1.png | 3 + .../longitudinal/distance_1_green.png | 3 + .../icons_mici/longitudinal/distance_2.png | 3 + .../longitudinal/distance_2_green.png | 3 + .../icons_mici/longitudinal/distance_3.png | 3 + .../longitudinal/distance_3_green.png | 3 + .../ui/mici/onroad/augmented_road_view.py | 4 + .../ui/mici/onroad/long_indicator.py | 89 +++++++++++++++++++ .../ui/mici/onroad/model_renderer.py | 2 +- 11 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 openpilot/selfdrive/assets/icons_mici/longitudinal/car.png create mode 100644 openpilot/selfdrive/assets/icons_mici/longitudinal/car_green.png create mode 100644 openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1.png create mode 100644 openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1_green.png create mode 100644 openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2.png create mode 100644 openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2_green.png create mode 100644 openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3.png create mode 100644 openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3_green.png create mode 100644 openpilot/selfdrive/ui/mici/onroad/long_indicator.py diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/car.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/car.png new file mode 100644 index 0000000000..221a8e1ceb --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/car.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0936a3d890a3ad68ffe021e4905b9b9e59ffc2ae19ee9c703daa3e7c92e7c78 +size 1793 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/car_green.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/car_green.png new file mode 100644 index 0000000000..82d757faf6 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/car_green.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57c7ba72642b0350ee764c924586fa66ff94915be4aba1813797e67eb4b74376 +size 33215 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1.png new file mode 100644 index 0000000000..3bccf1b0be --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71dc069bd3dc5b11ba8e1b52eb3a1025ee0ad8c82834f94b4b2269ec0d401920 +size 237 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1_green.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1_green.png new file mode 100644 index 0000000000..038d82958f --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1_green.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73a6fe02cf19105c0051a6664a424c1bf2ede364c6cf3791f235c86ffa203f39 +size 10348 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2.png new file mode 100644 index 0000000000..c164b7b9d4 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f500f118dca3eedbd86fc6cc943ad8be67fe5d4f7b474d5b452cd757ad805c3 +size 282 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2_green.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2_green.png new file mode 100644 index 0000000000..b2b56b6c01 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2_green.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a49996f0ce069a048abe94ba9424de655dcff53745676ab35e045204af88a3c +size 12103 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3.png new file mode 100644 index 0000000000..ef4b57207d --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8542374779f1908dc52c8a126bc041efea4c4d605afdd34e3bf8e520a7e78bf2 +size 320 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3_green.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3_green.png new file mode 100644 index 0000000000..f4688fc2d3 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3_green.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21e656b387c1a7e8de54d3e1b1c246f442cffe3c5e0924273d6d6d897c00b568 +size 13868 diff --git a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py index 4b19f0db5f..eecc196946 100644 --- a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -8,6 +8,7 @@ from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH from openpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer +from openpilot.selfdrive.ui.mici.onroad.long_indicator import LongIndicator from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView @@ -170,6 +171,7 @@ class AugmentedRoadView(CameraView): self._model_renderer = ModelRenderer() self._hud_renderer = HudRenderer() + self._long_indicator = LongIndicator() self._alert_renderer = AlertRenderer() self._driver_state_renderer = DriverStateRenderer() self._confidence_ball = ConfidenceBall() @@ -246,6 +248,8 @@ class AugmentedRoadView(CameraView): self._driver_state_renderer.set_should_draw(should_draw_dmoji) self._driver_state_renderer.set_position(self._rect.x + 16, self._rect.y + 10) self._driver_state_renderer.render() + self._long_indicator.set_should_draw(not self._hud_renderer.drawing_top_icons()) + self._long_indicator.render(self._content_rect) self._hud_renderer.set_can_draw_top_icons(alert_to_render is None) self._hud_renderer.set_wheel_critical_icon(alert_to_render is not None and not not_animating_out and diff --git a/openpilot/selfdrive/ui/mici/onroad/long_indicator.py b/openpilot/selfdrive/ui/mici/onroad/long_indicator.py new file mode 100644 index 0000000000..1ff83082a2 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/onroad/long_indicator.py @@ -0,0 +1,89 @@ +import pyray as rl +from openpilot.cereal import log +from openpilot.common.filter_simple import FirstOrderFilter +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 + +HIGHLIGHT_TIME = 2.5 # seconds + + +class LongIndicator(Widget): + def __init__(self): + super().__init__() + self._txt_lead_car = (self._texture('car', 35, 27), self._texture('car_green', 50, 42)) + self._txt_distance = [(self._texture('distance_1', 32, 7), self._texture('distance_1_green', 60, 35)), + (self._texture('distance_2', 40, 9), self._texture('distance_2_green', 68, 37)), + (self._texture('distance_3', 48, 11), self._texture('distance_3_green', 76, 39))] + self._alpha_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps) + # crossfade between states, a white and a green filter per icon + self._lead_car_filters = (FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps), FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)) + self._distance_filters = [(FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps), FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)) + for _ in range(3)] + self._personality: int | None = None + self._personality_changed_time = -HIGHLIGHT_TIME + self._should_draw = False + + @staticmethod + def _texture(name: str, width: int, height: int) -> rl.Texture: + return gui_app.texture(f'icons_mici/longitudinal/{name}.png', width, height, keep_aspect_ratio=False) + + def set_should_draw(self, should_draw: bool): + self._should_draw = should_draw + + def _render(self, rect: rl.Rectangle) -> None: + sm = ui_state.sm + if sm.recv_frame['selfdriveState'] < ui_state.started_frame or not sm['selfdriveState'].enabled or not ui_state.has_longitudinal_control: + self._personality = None + self._personality_changed_time = -HIGHLIGHT_TIME + self._alpha_filter.x = 0.0 + return + + # hidden under alerts and set speed + visible = self._should_draw and sm['selfdriveState'].alertSize == log.SelfdriveState.AlertSize.none + alpha = self._alpha_filter.update(visible) + self._draw_lead_car(rect, alpha) + self._draw_distance_bars(rect, alpha, visible) + + def _draw_lead_car(self, rect: rl.Rectangle, alpha: float) -> None: + sm = ui_state.sm + plan = sm['longitudinalPlan'] + has_lead = sm.alive['longitudinalPlan'] and plan.hasLead + + e2e = has_lead and plan.longitudinalPlanSource == log.LongitudinalPlan.LongitudinalPlanSource.e2e + white_f, green_f = self._lead_car_filters + white_alpha = white_f.update(0.0 if e2e else 0.9 if has_lead else 0.35) + green_alpha = green_f.update(float(e2e)) + + white, green = self._txt_lead_car + self._draw_centered(white, rect, 100, white_alpha * alpha) + self._draw_centered(green, rect, 100, green_alpha * alpha) + + def _draw_distance_bars(self, rect: rl.Rectangle, alpha: float, visible: bool) -> None: + sm = ui_state.sm + now = rl.get_time() + personality = sm['selfdriveState'].personality.raw + if self._personality is not None and personality != self._personality: + self._personality_changed_time = now + self._personality = personality + # the personality alert covers the bars, hold the highlight until they show + if not visible and now - self._personality_changed_time < HIGHLIGHT_TIME: + self._personality_changed_time = now + highlight = now - self._personality_changed_time < HIGHLIGHT_TIME + + # blink at double the turn signal rate (2.67 Hz) while overriding the gas + overriding = any(e.name == log.OnroadEvent.EventName.gasPressedOverride for e in sm['onroadEvents']) + blink = 0.35 / 0.9 if overriding and now % 0.375 > 0.1875 else 1.0 + + count = personality + 1 + for i, ((white, green), y, (active_f, green_f)) in enumerate(zip(self._txt_distance, (122, 136, 152), self._distance_filters, strict=True)): + active = active_f.update(float(i < count)) + green_alpha = green_f.update(float(highlight and i == count - 1)) + # only lit bars blink on override + self._draw_centered(white, rect, y, (0.35 * (1 - active) + 0.9 * (active - green_alpha) * blink) * alpha) + self._draw_centered(green, rect, y, green_alpha * blink * alpha) + + @staticmethod + def _draw_centered(texture: rl.Texture, rect: rl.Rectangle, y: float, alpha: float) -> None: + pos = rl.Vector2(rect.x + 46 - texture.width / 2, rect.y + y - texture.height / 2) + rl.draw_texture_ex(texture, pos, 0.0, 1.0, rl.Color(255, 255, 255, round(255 * alpha))) diff --git a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py index e927fa506e..e5dcfc44bf 100644 --- a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py @@ -180,7 +180,7 @@ class ModelRenderer(Widget): ss, cs = sm['selfdriveState'], sm['carState'] # braking disengages without making openpilot unavailable available = ss.enabled or ss.engageable or cs.brakePressed - lane = (self._lane_lines[1].raw_points + self._lane_lines[2].raw_points) / 2 + lane = self._path.raw_points opacity = 0.4 if ui_state.status == UIStatus.DISENGAGED else 0.8 for lead, (present, d_rel, y_rel) in zip(self._lead_vehicles, leads, strict=True): visible = available and present and d_rel < MAX_DRAW_DISTANCE and len(lane) > 0 From a0d47bcd08d9c359ccc7bd73145ed90ade2255ab Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 6 Oct 2026 18:43:50 -0700 Subject: [PATCH 198/270] modeld: drop chestnut on fallback (#39044) --- openpilot/selfdrive/modeld/modeld.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 0d90f1225e..dbb64ec281 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -77,6 +77,12 @@ def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log. shouldStop=bool(stop)) +def drop_chestnut() -> None: + if "AMD" in Device._opened_devices: + for d in Device._opened_devices: + Device[d].pending.pop(Device["AMD"], None) + + class ChestnutGpuState: # GPU metrics require modeld's GPU context def __init__(self, pm: PubMaster, big: bool): @@ -281,6 +287,8 @@ def main(demo=False): loader.join(BIG_MODEL_TIMEOUT) model = big_model params.put_bool("ChestnutActive", model is not None) + if model is None: + drop_chestnut() small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or CHESTNUT else None if model is None: @@ -413,6 +421,7 @@ def main(demo=False): raise # fallback to small model cloudlog.exception("big model failed, fall back to small") + drop_chestnut() params.put_bool("ChestnutActive", False) model = small_model if chestnut_state is not None: From 0e0c7c711d82992c98bcaea58e47bb50a255616a Mon Sep 17 00:00:00 2001 From: Warren Date: Tue, 6 Oct 2026 22:27:13 -0400 Subject: [PATCH 199/270] Mountain Dew Model (#39045) big model: desires-final (ea1600cb/12864) --- openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl index 7925e6d828..002f78a658 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cc75858d5e007b725b6264e194da4321d7874a212dc631b8f40a215dfd172cd2 -size 799992286 +oid sha256:4fe66440ef38c94ddbe315e14174912975014a96384201a8a632aabbe2302d66 +size 773090994 From f59056e5b6f92e3136d59ae403097cf0653442ce Mon Sep 17 00:00:00 2001 From: Warren Date: Tue, 6 Oct 2026 22:34:21 -0400 Subject: [PATCH 200/270] Revert "Mountain Dew Model" (#39046) Revert "Mountain Dew Model (#39045)" This reverts commit 0e0c7c711d82992c98bcaea58e47bb50a255616a. --- openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl index 002f78a658..7925e6d828 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4fe66440ef38c94ddbe315e14174912975014a96384201a8a632aabbe2302d66 -size 773090994 +oid sha256:cc75858d5e007b725b6264e194da4321d7874a212dc631b8f40a215dfd172cd2 +size 799992286 From 657d3a4f7c248fd3dd37d6520bf4ea91eeba57d7 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Tue, 6 Oct 2026 23:43:16 -0700 Subject: [PATCH 201/270] Fix Athena test port collision (#39048) --- openpilot/system/athena/tests/test_athenad.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpilot/system/athena/tests/test_athenad.py b/openpilot/system/athena/tests/test_athenad.py index f9e4c0d334..a89c8800f9 100644 --- a/openpilot/system/athena/tests/test_athenad.py +++ b/openpilot/system/athena/tests/test_athenad.py @@ -58,9 +58,7 @@ def host(): class TestAthenadMethods(OpenpilotTestCase): @classmethod def setup_class(cls): - cls.SOCKET_PORT = 45454 athenad.Api = MockApi # ty: ignore[invalid-assignment] # test double - athenad.LOCAL_PORT_WHITELIST = {cls.SOCKET_PORT} def setup_method(self): self.default_params = { @@ -434,7 +432,7 @@ class TestAthenadMethods(OpenpilotTestCase): assert athenad.upload_queue.qsize() == 1 assert asdict(athenad.upload_queue.queue[-1]) == asdict(item1) - def test_start_local_proxy(self, mock_create_connection): + def test_start_local_proxy(self, mock_create_connection, mocker): end_event = threading.Event() ws_recv = queue.Queue() @@ -442,11 +440,13 @@ class TestAthenadMethods(OpenpilotTestCase): mock_ws = MockWebsocket(ws_recv, ws_send) mock_create_connection.return_value = mock_ws - echo_socket = EchoSocket(self.SOCKET_PORT) + echo_socket = EchoSocket(0) + port = echo_socket.socket.getsockname()[1] + mocker.patch('openpilot.system.athena.athenad.LOCAL_PORT_WHITELIST', {port}) socket_thread = threading.Thread(target=echo_socket.run) socket_thread.start() - athenad.startLocalProxy(end_event, 'ws://localhost:1234', self.SOCKET_PORT) + athenad.startLocalProxy(end_event, 'ws://localhost:1234', port) ws_recv.put_nowait(b'ping') try: From f21bfc36be5f423f31b2c2a948d1e2eff91b02e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Wed, 7 Oct 2026 07:59:43 -0700 Subject: [PATCH 202/270] Enable Experimental Mode by default without confirmation (#39043) * Enable Experimental Mode by default without confirmation * Update toggles.py * Fix formatting in End-to-End Longitudinal Control description --- openpilot/common/params_keys.h | 3 +- .../selfdrive/ui/layouts/settings/toggles.py | 38 ++------------- openpilot/selfdrive/ui/mici/layouts/home.py | 2 +- .../ui/mici/layouts/settings/toggles.py | 48 ++----------------- openpilot/selfdrive/ui/onroad/exp_button.py | 9 +--- openpilot/selfdrive/ui/ui_state.py | 2 - 6 files changed, 10 insertions(+), 92 deletions(-) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 6a334a7020..93374fdc02 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -40,8 +40,7 @@ inline static std::unordered_map keys = { {"DriverTooDistracted", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, BOOL}}, {"DriverLockoutCount", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, INT, "0"}}, {"AlphaLongitudinalEnabled", {PERSISTENT | DEVELOPMENT_ONLY, BOOL}}, - {"ExperimentalMode", {PERSISTENT, BOOL}}, - {"ExperimentalModeConfirmed", {PERSISTENT, BOOL}}, + {"ExperimentalMode", {PERSISTENT, BOOL, "1"}}, {"FirmwareQueryDone", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"ForcePowerDown", {PERSISTENT, BOOL}}, {"GitBranch", {PERSISTENT, STRING}}, diff --git a/openpilot/selfdrive/ui/layouts/settings/toggles.py b/openpilot/selfdrive/ui/layouts/settings/toggles.py index 090c49f14e..06091782b1 100644 --- a/openpilot/selfdrive/ui/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/layouts/settings/toggles.py @@ -3,10 +3,7 @@ from openpilot.common.params import Params, UnknownKeyName from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.list_view import multiple_button_item, toggle_item from openpilot.system.ui.widgets.scroller_tici import Scroller -from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog -from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop -from openpilot.system.ui.widgets import DialogResult from openpilot.selfdrive.ui.ui_state import ui_state PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants @@ -156,15 +153,9 @@ class TogglesLayout(Widget): ui_state.update_params() e2e_description = tr( - "openpilot defaults to driving in chill mode. Experimental mode enables alpha-level features that aren't ready for chill mode. " + - "Experimental features are listed below:
" + "

End-to-End Longitudinal Control


" + "Let the driving model control the gas and brakes. openpilot will drive as it thinks a human would, including stopping for red lights and stop signs. " + - "Since the driving model decides the speed to drive, the set speed will only act as an upper bound. This is an alpha quality feature; " + - "mistakes should be expected.
" + - "

New Driving Visualization


" + - "The driving visualization will transition to the road-facing wide-angle camera at low speeds to better show some turns. " + - "The Experimental mode logo will also be shown in the top right corner." + "Since the driving model decides the speed to drive, the set speed will only act as an upper bound. Mistakes should be expected.
" ) if ui_state.CP is not None: @@ -212,32 +203,11 @@ class TogglesLayout(Widget): icon = "experimental.png" if self._toggles["ExperimentalMode"].action_item.get_state() else "experimental_white.png" self._toggles["ExperimentalMode"].set_icon(icon) - def _handle_experimental_mode_toggle(self, state: bool): - confirmed = self._params.get_bool("ExperimentalModeConfirmed") - if state and not confirmed: - def confirm_callback(result: DialogResult): - if result == DialogResult.CONFIRM: - self._params.put_bool("ExperimentalMode", True, block=True) - self._params.put_bool("ExperimentalModeConfirmed", True, block=True) - else: - self._toggles["ExperimentalMode"].action_item.set_state(False) - self._update_experimental_mode_icon() - - # show confirmation dialog - content = (f"

{self._toggles['ExperimentalMode'].title}


" + - f"

{self._toggles['ExperimentalMode'].description}

") - dlg = ConfirmDialog(content, tr("Enable"), rich=True, callback=confirm_callback) - gui_app.push_widget(dlg) - else: - self._update_experimental_mode_icon() - self._params.put_bool("ExperimentalMode", state, block=True) - def _toggle_callback(self, state: bool, param: str): - if param == "ExperimentalMode": - self._handle_experimental_mode_toggle(state) - return - self._params.put_bool(param, state, block=True) + if param == "ExperimentalMode": + self._update_experimental_mode_icon() + if self._toggle_defs[param][3]: self._params.put_bool("OnroadCycleRequested", True, block=True) diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index b852bf9ea6..14370d67c4 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -173,7 +173,7 @@ class MiciHomeLayout(Widget): if self._mouse_down_t is not None: if time.monotonic() - self._mouse_down_t > 0.5: # long gating for experimental mode - only allow toggle if longitudinal control is available - if ui_state.has_longitudinal_control and ui_state.experimental_mode_confirmed: + if ui_state.has_longitudinal_control: ui_state.experimental_mode = not ui_state.experimental_mode ui_state.params.put("ExperimentalMode", ui_state.experimental_mode, block=True) self._mouse_down_t = None diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index 40a6188579..0cd552c6cd 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -1,10 +1,7 @@ -from collections.abc import Callable - from openpilot.cereal import log from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle, BigToggle, GreyBigButton -from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationCircleButton +from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback from openpilot.selfdrive.ui.ui_state import ui_state @@ -12,31 +9,6 @@ from openpilot.selfdrive.ui.ui_state import ui_state PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants -class ExperimentalModeConfirmPage(NavScroller): - def __init__(self, on_confirm: Callable[[], None]): - super().__init__() - - accept = BigConfirmationCircleButton("enable\nexperimental mode", - gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 64, 64), - lambda: self.dismiss(on_confirm)) - - self._scroller.add_widgets([ - GreyBigButton("enabling\nexperimental mode", "scroll to continue", - gui_app.texture("icons_mici/setup/warning.png", 64, 64)), - GreyBigButton("", "openpilot defaults to driving in chill mode."), - GreyBigButton("", "Experimental mode enables alpha-level features that aren't ready for chill mode."), - GreyBigButton("End-to-End Longitudinal Control"), - GreyBigButton("", "Let the driving model control the gas and brakes."), - GreyBigButton("", "openpilot will drive as it thinks a human would, including stopping for red lights and stop signs."), - GreyBigButton("", "The set speed will only act as an upper bound."), - GreyBigButton("", "This is an alpha quality feature; mistakes should be expected."), - GreyBigButton("New Driving Visualization"), - GreyBigButton("", "The path will change colors to communicate acceleration intent."), - GreyBigButton("", "Red for braking, green for acceleration, and gray for coasting."), - accept, - ]) - - class TogglesLayoutMici(NavScroller): def __init__(self): super().__init__() @@ -46,8 +18,8 @@ class TogglesLayoutMici(NavScroller): "Aggressive follows closer, with firmer gas and braking.\n" + "Relaxed leaves more space.\n" + "Use the steering wheel distance button on supported cars.") - self._experimental_btn = BigToggle("experimental mode", description_icon=gui_app.texture("icons_mici/experimental_mode.png", 64, 64), - initial_state=ui_state.params.get_bool("ExperimentalMode"), toggle_callback=self._on_experimental_mode, + self._experimental_btn = BigParamControl("experimental mode", "ExperimentalMode", + description_icon=gui_app.texture("icons_mici/experimental_mode.png", 64, 64), description="Let the driving model control gas and brakes.\n" + "Includes stopping for red lights and stop signs.\n" + "Set speed is a maximum, not a target.\n" + @@ -132,17 +104,3 @@ class TogglesLayoutMici(NavScroller): # Refresh toggles from params to mirror external changes for key, item in self._refresh_toggles: item.set_checked(ui_state.params.get_bool(key)) - - def _on_experimental_mode(self, state: bool): - if state and not ui_state.params.get_bool("ExperimentalModeConfirmed"): - # Don't show enabled state until confirm - self._experimental_btn.set_checked(False) - - def on_confirm(): - ui_state.params.put_bool("ExperimentalModeConfirmed", True) - ui_state.params.put_bool("ExperimentalMode", True) - self._experimental_btn.set_checked(True) - - gui_app.push_widget(ExperimentalModeConfirmPage(on_confirm)) - else: - ui_state.params.put_bool("ExperimentalMode", state) diff --git a/openpilot/selfdrive/ui/onroad/exp_button.py b/openpilot/selfdrive/ui/onroad/exp_button.py index 9a92ebc3c3..a122394c88 100644 --- a/openpilot/selfdrive/ui/onroad/exp_button.py +++ b/openpilot/selfdrive/ui/onroad/exp_button.py @@ -34,7 +34,7 @@ class ExpButton(Widget): def _handle_mouse_release(self, _): super()._handle_mouse_release(_) - if self._is_toggle_allowed(): + if ui_state.has_longitudinal_control: new_mode = not self._experimental_mode self._params.put_bool("ExperimentalMode", new_mode) @@ -61,10 +61,3 @@ class ExpButton(Widget): self._hold_end_time = self._held_mode = None return self._experimental_mode - - def _is_toggle_allowed(self): - if not self._params.get_bool("ExperimentalModeConfirmed"): - return False - - # Mirror exp mode toggle using persistent car params - return ui_state.has_longitudinal_control diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index 9489673039..647f977677 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -86,7 +86,6 @@ class UIState: self.is_release = self.params.get_bool("IsReleaseBranch") self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM") self.experimental_mode: bool = self.params.get_bool("ExperimentalMode") - self.experimental_mode_confirmed: bool = self.params.get_bool("ExperimentalModeConfirmed") self.chestnut_present: bool = False self.chestnut_compiled: bool = chestnut_compiled() self.chestnut_active: bool | None = None @@ -246,7 +245,6 @@ class UIState: self.is_metric = self.params.get_bool("IsMetric") self.always_on_dm = self.params.get_bool("AlwaysOnDM") self.experimental_mode = self.params.get_bool("ExperimentalMode") - self.experimental_mode_confirmed = self.params.get_bool("ExperimentalModeConfirmed") self.chestnut_active = self.params.get("ChestnutActive") self.chestnut_loading = self.params.get_bool("ChestnutLoading") now = time.monotonic() From 3f06ed3bc7a4cc9c320be1f7d03dcd8a885a8e6f Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Wed, 7 Oct 2026 15:13:02 -0700 Subject: [PATCH 203/270] chestnut: show switch branch alert only on release (#39049) chestnut: only show branch alert on release branches --- openpilot/system/hardware/chestnut/status.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpilot/system/hardware/chestnut/status.py b/openpilot/system/hardware/chestnut/status.py index 4e6c62fd08..c9e7982489 100644 --- a/openpilot/system/hardware/chestnut/status.py +++ b/openpilot/system/hardware/chestnut/status.py @@ -1,6 +1,7 @@ import time from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, is_chestnut_usb_id +from openpilot.common.version import RELEASE_BRANCHES CHESTNUT_RELEASE_BRANCHES = ("release-chestnut", "release-chestnut-staging", "nightly-chestnut", "nightly-chestnut-dev") @@ -82,7 +83,7 @@ class ChestnutStatus: release = branch in CHESTNUT_RELEASE_BRANCHES missing = self.usb_failed or (offroad and release and time.monotonic() - self.started > 10. and len(detected) != 1) slow_usb = offroad and len(devices) == 1 and devices[0]["speedMbps"] < 5000 - set_alert("Offroad_ChestnutBranch", not release and len(devices) == 1) + set_alert("Offroad_ChestnutBranch", branch in RELEASE_BRANCHES and not release and len(devices) == 1) set_alert("Offroad_ChestnutNotDetected", missing) set_alert("Offroad_ChestnutOverheated", self.overheated, f"{state.tempC:.0f} °C" if state is not None else None) set_alert("Offroad_ChestnutUsbSlow", slow_usb, f"{devices[0]['speedMbps']} Mbps" if slow_usb else None) From f89ce23f9d1a09345a531233fcb8421a7ceb7be3 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Wed, 7 Oct 2026 19:23:52 -0700 Subject: [PATCH 204/270] chestnut: align alerts (#39051) chestnut: reword alerts --- openpilot/selfdrive/selfdrived/alerts_offroad.json | 2 +- openpilot/system/hardware/chestnut/status.py | 14 +++++++------- openpilot/system/hardware/hardwared.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/openpilot/selfdrive/selfdrived/alerts_offroad.json b/openpilot/selfdrive/selfdrived/alerts_offroad.json index 0e99aa5418..8316d093b0 100644 --- a/openpilot/selfdrive/selfdrived/alerts_offroad.json +++ b/openpilot/selfdrive/selfdrived/alerts_offroad.json @@ -30,7 +30,7 @@ "severity": 0 }, "Offroad_ChestnutOverheated": { - "text": "Chestnut overheated. Ensure good airflow. Current GPU temperature is %1.", + "text": "Chestnut overheated. Ensure good airflow.", "severity": 0 }, "Offroad_ChestnutPcieUnavailable": { diff --git a/openpilot/system/hardware/chestnut/status.py b/openpilot/system/hardware/chestnut/status.py index c9e7982489..04a3dff821 100644 --- a/openpilot/system/hardware/chestnut/status.py +++ b/openpilot/system/hardware/chestnut/status.py @@ -49,9 +49,9 @@ class ChestnutStatus: self.model_loading_seen |= model_loading self.model_attempted |= self.model_loading_seen and not model_loading and model_active is not None - self.model_failed |= not offroad and self.model_attempted and model_active is False + self.model_failed |= not offroad and self.model_attempted and model_active is False and len(detected) > 0 - if not offroad and self.usb_seen and not firmware_ok: + if not offroad and self.usb_seen and len(devices) != 1: self.usb_failed = True if not offroad and state is not None: @@ -85,14 +85,14 @@ class ChestnutStatus: slow_usb = offroad and len(devices) == 1 and devices[0]["speedMbps"] < 5000 set_alert("Offroad_ChestnutBranch", branch in RELEASE_BRANCHES and not release and len(devices) == 1) set_alert("Offroad_ChestnutNotDetected", missing) - set_alert("Offroad_ChestnutOverheated", self.overheated, f"{state.tempC:.0f} °C" if state is not None else None) + set_alert("Offroad_ChestnutOverheated", self.overheated) set_alert("Offroad_ChestnutUsbSlow", slow_usb, f"{devices[0]['speedMbps']} Mbps" if slow_usb else None) if self.power_lost: - pcie_alert = ("Chestnut power restored. 12V is stable again, cycle ignition." if self.power_restored else - "Chestnut power disconnected. Check 12V connection, then cycle ignition." if self.power_unavailable else - "Chestnut power lost. Possibly caused by an engine-crank voltage drop. Check 12V connection, then cycle ignition.") + pcie_alert = ("Chestnut power restored. Restart the car to retry." if self.power_restored else + "Chestnut power disconnected. Check the 12V connection, then restart the car." if self.power_unavailable else + "Chestnut power lost. Check the 12V connection, then restart the car.") else: - pcie_alert = "Chestnut GPU unavailable. PCIe link is not up. Check the GPU is securely seated." + pcie_alert = "Chestnut GPU unavailable. Check the GPU is securely seated, then restart the car." set_alert("Offroad_ChestnutPcieUnavailable", self.pcie_failed, pcie_alert) set_alert("Offroad_ChestnutModelError", self.model_failed and not (missing or self.pcie_failed)) set_alert("Offroad_ChestnutUpdateFailed", offroad and firmware_failed) diff --git a/openpilot/system/hardware/hardwared.py b/openpilot/system/hardware/hardwared.py index c083c75b52..e7de070e8e 100755 --- a/openpilot/system/hardware/hardwared.py +++ b/openpilot/system/hardware/hardwared.py @@ -313,7 +313,7 @@ def hardware_thread(end_event, hw_queue) -> None: chestnut.update(started_ts is None, last_hw_state.usb_state) chestnut_state = sm["chestnutState"] chestnut_valid = sm.alive["chestnutState"] and sm.valid["chestnutState"] - chestnut_status.update(started_ts is None, branch, last_hw_state.usb_state, chestnut.failed, + chestnut_status.update(started_ts is None or not onroad_conditions["ignition"], branch, last_hw_state.usb_state, chestnut.failed, params.get_bool("ChestnutLoading"), params.get("ChestnutActive"), chestnut_state if chestnut_valid else None, set_offroad_alert_if_changed) # this subset is only used for offroad From a742df61827f8b34eee1af99bbb881201163e472 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Wed, 7 Oct 2026 19:24:08 -0700 Subject: [PATCH 205/270] chestnut: bump fw (#39050) bump chestnut fw --- openpilot/common/hardware/usb.py | 2 +- .../hardware/chestnut/firmware_wrapped.bin | Bin 9207 -> 9235 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/common/hardware/usb.py b/openpilot/common/hardware/usb.py index ba1d4f0cbc..55d970a245 100644 --- a/openpilot/common/hardware/usb.py +++ b/openpilot/common/hardware/usb.py @@ -1,7 +1,7 @@ import os from pathlib import Path -CHESTNUT_FW_VERSION = "ed4e39b7" +CHESTNUT_FW_VERSION = "8516b768" CHESTNUT_USB_IDS = ((0xADD1, 0x0001), (0x3801, 0x0001)) CHESTNUT_ROM_USB_IDS = ((0x174C, 0x2464), (0x174C, 0x2463)) CHESTNUT_USB_PRODUCT = f"custom {CHESTNUT_FW_VERSION}-CLEAN" diff --git a/openpilot/system/hardware/chestnut/firmware_wrapped.bin b/openpilot/system/hardware/chestnut/firmware_wrapped.bin index 1d738e4a759d05a8905e6ddf1b5d5ee6a324b04d..ff0381a6ebf78c1047091e16892bafcc137d9190 100644 GIT binary patch delta 1054 zcmZ8eT}V@57(U-=+u708>gb$fn>%O6sHDj>o2!frp}&g?S6zhE`ayySA~yO_=Q_j4 zrR%d#?ZS&7A=4tNWfWoUA_~HbU35`Iu-5tk)18Y>mO1S^+o^`<;^Tea=Xu_r=S*)! z+ZyO{f*^?!vWz40KhidHWa<9Qof^pPL zI>30em#k7(PXxGDhSUbyGD_ehM;(T=mu}Ms?5&kDvJnSoap2RBI_S2CXqwu&_u$a<_8jJ6gz0m1jF^A*M=1Mg&vozbPz2(zE9J_%la4<)5O#<6+>4+UWebf%BOF_H{*&2^2^W? zqqUeX&e3F89oNJu$lK5+qZ8QCNn?`*-tSLj*aQvlS0plf65hg;R3ojGTvF}aJQ_D{ z1~X_$Y3;6Rd6Lp1BIFW0!j&y`3o@pp9UcM49gXsfM=6@Alln2Wu4A{rP}d>vM!luq z!A8_+ssrBW3)4P8i(?`gTtu2hwA>s59yDxjT;7~^rZf;<@o7caqe{#@t(Pg*gRn=9 zlPuqQ5V4jUpb5RS^e#Oj|Lm$bby=B>j$2=X5wxa^Yd?)m$nD>qw-`m*My8w}3b3x! z9M5GRDzU-wFy-6miM}5s}C^ zlNQMLBdLt78OqaT{d<59f;~h+%HKM;cgp$M4PjCq^#pCVnGgKi3??T<2tWErF=&GP zE8I^bd!mWtH%L2^2FSlt?1Il%`+x|aJ5pkL&x{u%$%RI$SQaI19QcV;>`Jnnh*q+l f01QPRaVDU3udQ+&u64PiZ|x?azI*k;tVmJt)>Onbx0b`63LfSV2m8V(+injjuL*h3Evpay zwQU|pMcaa}Mx6%In^2VZQw)75K-np+i-;U(^0m;Fbg3J~a0OWp+8iwQ7J2z059Vnu z1t{&Tf+$^dOQ0KVc|A^^kPsjvt1`05+)Pg%a?*POD|&q$={7*JBAUp!MzP|fuMEzO zx-$mNIRD)9LX0`p4MLQTrbadW{D5arTw~Y)}#s|AU1oA2}V zi(eRXD+=lau!vq}=30fgzt8QQr1w*Pm0#&sFmyiTL>vPOhqe}b#vME4qG-3?n8E!_ z&?HzHPz5MxNkOOdHlRYy`dYNAF9S;`FRQRBQyM0-ynLdtL(9H+YlxRHx$Lp?IPa;E zBAzB|)|<66%3VFxJVQN%76Y2h`VP$Kadrtf?F(mL1axplB)d|`oFFR8@qj|~Ca30L zWyB_^AiV2Pi*Q&G+Im$gQHet^0ozm0m0Z-Ez8 z?KitGW1orrhmsbfNRxNnww#J}`t3lXTFHY5gksQApCpm}$!;jjSQF#gu%tC!m$e`B zARLvnH+E>b|EHiZMHUNoh=@?k7B)aj8`^7Ps|S*F@%xQoLvWY~MU%Jgy0C8hR0LsI zdg?)vB(Wx3`A0|Hrp$^7%uq>(QF)W6L~LOlw9LvkgL6k5K!kHvL5!ULRfm!Jo!4Yp t7Hyf#U;(jgE@=^bm24jX1HPa+8>kx_jyDz*Un%rOxon^un>tJ{`~ezCcai`A From 8d1865bdc69d605821db7227500079329fdfd550 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 8 Oct 2026 11:13:52 -0700 Subject: [PATCH 206/270] ui: sync gpu with offroad state (#39053) ui: show chestnut icon before selfdriveState --- openpilot/selfdrive/ui/mici/onroad/hud_renderer.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py index 3bb2f70b84..791de05306 100644 --- a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py @@ -190,9 +190,6 @@ class HudRenderer(Widget): self._draw_steering_wheel(rect) def _draw_model_source(self, rect: rl.Rectangle) -> None: - if ui_state.sm.recv_frame['selfdriveState'] < ui_state.started_frame: - return - loading = ui_state.chestnut_state == ChestnutState.LOADING if loading: icon = self._txt_chestnut From 28917bb3ab3f246725c475f14f94ee5416de2f91 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 8 Oct 2026 11:15:34 -0700 Subject: [PATCH 207/270] soundd: louder warning sounds (#39052) --- openpilot/selfdrive/assets/sounds/critical_max.wav | 3 +++ openpilot/selfdrive/assets/sounds/dm_critical_max.wav | 2 +- openpilot/selfdrive/assets/sounds/warning.wav | 2 +- openpilot/selfdrive/ui/soundd.py | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 openpilot/selfdrive/assets/sounds/critical_max.wav diff --git a/openpilot/selfdrive/assets/sounds/critical_max.wav b/openpilot/selfdrive/assets/sounds/critical_max.wav new file mode 100644 index 0000000000..c3ad9de84d --- /dev/null +++ b/openpilot/selfdrive/assets/sounds/critical_max.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d02369e21d3c78a3a5ece7e78deedb82343bc3228a28db73da48b5c27860004 +size 52374 diff --git a/openpilot/selfdrive/assets/sounds/dm_critical_max.wav b/openpilot/selfdrive/assets/sounds/dm_critical_max.wav index 9526364f99..17eb686ee2 100644 --- a/openpilot/selfdrive/assets/sounds/dm_critical_max.wav +++ b/openpilot/selfdrive/assets/sounds/dm_critical_max.wav @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c4699e3a456a0df2bbfac7ab1eb8972fc8bd970296133fd322e214c61884407 +oid sha256:3bef898e4e784baf98f3ceca53cbe95b1020da5e01162cdaa14b79b1ce0b1340 size 52374 diff --git a/openpilot/selfdrive/assets/sounds/warning.wav b/openpilot/selfdrive/assets/sounds/warning.wav index bb71a0176a..dae0658ef0 100644 --- a/openpilot/selfdrive/assets/sounds/warning.wav +++ b/openpilot/selfdrive/assets/sounds/warning.wav @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1bb440f424c989c06e203bfba0b32d39aede8f6c78a9b335e5e778460202b601 +oid sha256:884e828e5c51d980d41b6e2400fcb4f4a926275018a00674d8239184e1eb96db size 73026 diff --git a/openpilot/selfdrive/ui/soundd.py b/openpilot/selfdrive/ui/soundd.py index aa0f54c5ad..69b10fcdc3 100644 --- a/openpilot/selfdrive/ui/soundd.py +++ b/openpilot/selfdrive/ui/soundd.py @@ -23,7 +23,7 @@ ALERT_MAX_TIME = 8 # seconds before critical alerts switch to the max sound SELFDRIVE_STATE_TIMEOUT = 5 # 5 seconds FILTER_DT = 1. / (micd.SAMPLE_RATE / micd.FFT_SAMPLES) -AMBIENT_DB = 26 # DB where MIN_VOLUME is applied +AMBIENT_DB = 22 # DB where MIN_VOLUME is applied DB_SCALE = 30 # AMBIENT_DB + DB_SCALE is where MAX_VOLUME is applied VOLUME_BASE = 20 From 6846d733d4782cb36b498ac49ed732cc53d3f24d Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Thu, 8 Oct 2026 13:16:29 -0700 Subject: [PATCH 208/270] jp: add segment and duration --- openpilot/tools/jotpluggler/app.h | 1 + openpilot/tools/jotpluggler/session.cc | 56 ++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/openpilot/tools/jotpluggler/app.h b/openpilot/tools/jotpluggler/app.h index e089609247..4211a754d0 100644 --- a/openpilot/tools/jotpluggler/app.h +++ b/openpilot/tools/jotpluggler/app.h @@ -680,6 +680,7 @@ struct UiState { bool has_shared_range = false; bool has_tracker_time = false; bool layout_dirty = false; + int route_time_zone = 0; bool playback_loop = false; bool playback_playing = false; bool show_deprecated_fields = false; diff --git a/openpilot/tools/jotpluggler/session.cc b/openpilot/tools/jotpluggler/session.cc index 4c694a8daa..71a62fc7e6 100644 --- a/openpilot/tools/jotpluggler/session.cc +++ b/openpilot/tools/jotpluggler/session.cc @@ -5,6 +5,7 @@ #include #include #include +#include namespace fs = std::filesystem; @@ -659,6 +660,9 @@ void draw_route_id_chip(AppSession *session, UiState *state) { ImGui::EndPopup(); } + ImGui::SetCursorScreenPos(ImVec2(chip_max.x + 16.0f, window->Pos.y)); + ImGui::Dummy(ImVec2(0.0f, window->Size.y)); + draw_route_copy_feedback(state, draw_list, chip_max); draw_route_info_popup(session, state, ImVec2(std::max(window->Pos.x + 16.0f, chip_max.x - 360.0f), chip_max.y + 6.0f)); } @@ -767,6 +771,58 @@ float draw_main_menu_bar(AppSession *session, UiState *state) { } ImGui::SameLine(0.0f, 8.0f); draw_route_id_chip(session, state); + if (session->data_mode == SessionDataMode::Route && state->has_tracker_time) { + const RouteSeries *wall = app_find_route_series(*session, "/initData/wallTimeNanos"); + const double route_start = wall && !wall->times.empty() ? wall->times.front() : 0.0; + const double elapsed = std::max(0.0, state->tracker_time - route_start); + int segment = static_cast(elapsed / 60.0); + for (const char *path : {"/narrowRoadEncodeIdx/segmentNum", "/wideRoadEncodeIdx/segmentNum", + "/qNarrowRoadEncodeIdx/segmentNum", "/cabinEncodeIdx/segmentNum"}) { + const RouteSeries *series = app_find_route_series(*session, path); + if (!series) continue; + auto value = app_sample_xy_value_at_time(series->times, series->values, true, state->tracker_time); + if (value) { + segment = static_cast(*value); + break; + } + } + ImGui::SameLine(0.0f, 0.0f); + const float segment_x = ImGui::GetCursorPosX(); + ImGui::Text("Segment %d", segment); + ImGui::SameLine(); + ImGui::SetCursorPosX(segment_x + ImGui::CalcTextSize(util::string_format("Segment %d", std::max(segment, session->route_id.available_end)).c_str()).x + 16.0f); + app_push_mono_font(); + const int total = static_cast(std::max(0.0, session->route_data.x_max - route_start)); + const int current = static_cast(elapsed); + const int minutes_width = std::max(2, static_cast(std::to_string(total / 60).size())); + ImGui::Text("%0*d:%02d / %0*d:%02d", minutes_width, current / 60, current % 60, minutes_width, total / 60, total % 60); + ImGui::SameLine(0.0f, 20.0f); + std::string clock_text = "--"; + if (wall && !wall->values.empty() && wall->values.front() > 0.0) { + const time_t seconds = static_cast(wall->values.front() / 1.0e9 + elapsed); + std::tm tm = {}; + std::string zone_name; + if (state->route_time_zone == 1) localtime_r(&seconds, &tm); + else gmtime_r(&seconds, &tm); + char text[64]; + std::strftime(text, sizeof(text), "%Y-%m-%d %H:%M:%S", &tm); + if (state->route_time_zone == 1) { + char name[32]; + std::strftime(name, sizeof(name), "%Z", &tm); + zone_name = name; + } + if (state->route_time_zone == 0) zone_name = "UTC"; + clock_text = util::string_format("%s %s", text, zone_name.c_str()); + } + if (ImGui::BeginMenu((clock_text + "###route_clock").c_str())) { + const char *zones[] = {"UTC", "Computer local"}; + for (int i = 0; i < 2; ++i) { + if (ImGui::MenuItem(zones[i], nullptr, state->route_time_zone == i)) state->route_time_zone = i; + } + ImGui::EndMenu(); + } + app_pop_mono_font(); + } height = ImGui::GetWindowSize().y; ImGui::EndMainMenuBar(); } From f804d3468e05820f11e3580d6aa797bc958c7b82 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Thu, 8 Oct 2026 13:54:23 -0700 Subject: [PATCH 209/270] jp: zoom improvements --- openpilot/tools/jotpluggler/app.h | 11 ++++++ openpilot/tools/jotpluggler/plot.cc | 55 +++++++++++++++++++++++++- openpilot/tools/jotpluggler/render.cc | 12 ++++++ openpilot/tools/jotpluggler/session.cc | 3 ++ 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/openpilot/tools/jotpluggler/app.h b/openpilot/tools/jotpluggler/app.h index 4211a754d0..dd6c81eb23 100644 --- a/openpilot/tools/jotpluggler/app.h +++ b/openpilot/tools/jotpluggler/app.h @@ -663,6 +663,11 @@ struct UndoStack { } }; +struct PlotView { + ImGuiID plot_id = 0; + PlotRange range; +}; + struct UiState { bool open_open_route = false; bool open_stream = false; @@ -734,6 +739,12 @@ struct UiState { bool init_data_selected = false; std::string init_data_search; UndoStack undo; + std::vector plot_view_history; + std::optional restore_plot_view; + ImGuiID last_plot_view_id = 0; + double last_plot_view_change = -1.0; + bool last_plot_view_wheel = false; + double last_plot_pan_click = -1.0; }; // app.cc public API diff --git a/openpilot/tools/jotpluggler/plot.cc b/openpilot/tools/jotpluggler/plot.cc index b8697c6872..59d08ff2c9 100644 --- a/openpilot/tools/jotpluggler/plot.cc +++ b/openpilot/tools/jotpluggler/plot.cc @@ -1,6 +1,7 @@ #include "tools/jotpluggler/internal.h" #include "implot.h" +#include "implot_internal.h" #include "imgui_internal.h" #include @@ -801,7 +802,13 @@ void draw_plot(const AppSession &session, Pane *pane, UiState *state) { plot_flags |= ImPlotFlags_NoLegend; } - const ImPlotAxisFlags x_axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight; + ImPlotAxisFlags x_axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight; + const ImGuiIO &input = ImGui::GetIO(); + const bool at_zoom_limit = state->x_view_max - state->x_view_min <= MIN_HORIZONTAL_ZOOM_SECONDS + 1.0e-6; + if (at_zoom_limit && (input.MouseWheel > 0.0f || input.MouseReleased[ImGuiMouseButton_Left])) { + // Further zoom-in must not move the time window; panning and zoom-out remain available. + x_axis_flags |= ImPlotAxisFlags_LockMin | ImPlotAxisFlags_LockMax; + } ImPlotAxisFlags y_axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight; if (state_block_mode) { y_axis_flags |= ImPlotAxisFlags_NoDecorations; @@ -817,6 +824,8 @@ void draw_plot(const AppSession &session, Pane *pane, UiState *state) { if (ImPlot::BeginPlot("##plot", plot_size, plot_flags)) { ImPlot::SetupAxes(nullptr, nullptr, x_axis_flags, y_axis_flags); ImPlot::SetupAxisFormat(ImAxis_X1, "%.1f"); + // ImPlot updates pan endpoints separately; a minimum span would distort the pan. + ImPlot::SetupAxisZoomConstraints(ImAxis_X1, input.MouseDown[ImGuiMouseButton_Right] ? 0.0 : MIN_HORIZONTAL_ZOOM_SECONDS, INFINITY); if (state_block_mode) { ImPlot::SetupAxisLimits(ImAxis_Y1, 0.0, 1.0, ImPlotCond_Always); } else if (pane_value_format.valid) { @@ -841,6 +850,28 @@ void draw_plot(const AppSession &session, Pane *pane, UiState *state) { ImPlot::SetupLegend(ImPlotLocation_NorthEast); } + ImPlotPlot *plot = ImPlot::GetCurrentPlot(); + const PlotRange previous_view = {.left = previous_x_min, .right = previous_x_max, + .bottom = plot->Axes[ImAxis_Y1].Range.Min, .top = plot->Axes[ImAxis_Y1].Range.Max}; + const bool was_selecting = plot->Selecting; + const bool restoring_view = state->restore_plot_view && state->restore_plot_view->plot_id == plot->ID; + if (restoring_view) { + ImPlot::SetupAxisLimits(ImAxis_Y1, state->restore_plot_view->range.bottom, state->restore_plot_view->range.top, ImPlotCond_Always); + state->restore_plot_view.reset(); + } + const ImGuiIO &io = ImGui::GetIO(); + const ImVec2 drag(io.MousePos.x - plot->SelectStart.x, io.MousePos.y - plot->SelectStart.y); + const bool zoom_ready = ImGui::GetTime() - io.MouseClickedTime[ImGuiMouseButton_Left] >= 0.09 + && (std::abs(drag.x) > 12.0f || std::abs(drag.y) > 12.0f); + // Cancel before ImPlot processes release; hide the box until both thresholds are met. + if (io.MouseReleased[ImGuiMouseButton_Left] && !zoom_ready) { + plot->Selecting = false; + } + ImPlot::SetupFinish(); + if (!zoom_ready) { + plot->Selected = false; + } + if (state_block_mode) { draw_state_blocks_pane(prepared_curves, state); } else { @@ -873,6 +904,28 @@ void draw_plot(const AppSession &session, Pane *pane, UiState *state) { state->tracker_time = std::clamp(ImPlot::GetPlotMousePos().x, state->route_x_min, state->route_x_max); state->has_tracker_time = true; } + const ImPlotRect view = ImPlot::GetPlotLimits(); + const bool changed = std::abs(view.X.Min - previous_view.left) > 1.0e-6 + || std::abs(view.X.Max - previous_view.right) > 1.0e-6 + || std::abs(view.Y.Min - previous_view.bottom) > 1.0e-6 + || std::abs(view.Y.Max - previous_view.top) > 1.0e-6; + const bool wheel = io.MouseWheel != 0.0f; + const bool pan = ImGui::IsMouseDown(ImGuiMouseButton_Right); + if (changed && (ImPlot::IsPlotHovered() || was_selecting || plot->Held) && (wheel || pan || io.MouseReleased[ImGuiMouseButton_Left]) + && !restoring_view && !state->suppress_range_side_effects && !plot->JustCreated) { + // Keep continuous pans and wheel bursts as one view-history entry. + const bool continuing = state->last_plot_view_id == plot->ID + && ((pan && state->last_plot_pan_click == io.MouseClickedTime[ImGuiMouseButton_Right]) + || (wheel && state->last_plot_view_wheel && ImGui::GetTime() - state->last_plot_view_change < 0.3)); + if (!continuing) { + if (state->plot_view_history.size() == 50) state->plot_view_history.erase(state->plot_view_history.begin()); + state->plot_view_history.push_back({plot->ID, previous_view}); + } + state->last_plot_view_id = plot->ID; + state->last_plot_view_change = ImGui::GetTime(); + state->last_plot_view_wheel = wheel; + state->last_plot_pan_click = io.MouseClickedTime[ImGuiMouseButton_Right]; + } ImPlot::EndPlot(); } app_pop_mono_font(); diff --git a/openpilot/tools/jotpluggler/render.cc b/openpilot/tools/jotpluggler/render.cc index f7d991d2f9..87f88c955d 100644 --- a/openpilot/tools/jotpluggler/render.cc +++ b/openpilot/tools/jotpluggler/render.cc @@ -68,6 +68,18 @@ void render_layout(AppSession *session, UiState *state, bool show_camera_feed) { if (!ImGui::GetIO().WantTextInput && ImGui::IsKeyPressed(ImGuiKey_Space, false)) { state->playback_playing = !state->playback_playing; } + // GLFW/ImGui mouse button 4 is the conventional mouse Back button. + if (ImGui::IsMouseClicked(3) && !state->plot_view_history.empty()) { + const PlotView view = state->plot_view_history.back(); + state->plot_view_history.pop_back(); + state->x_view_min = view.range.left; + state->x_view_max = view.range.right; + state->restore_plot_view = view; + state->follow_latest = false; + state->playback_playing = false; + state->suppress_range_side_effects = true; + state->last_plot_view_id = 0; + } advance_playback(state, *session); CameraFeedView *sidebar_camera = session->pane_camera_feeds[static_cast(sidebar_preview_camera_view(*session))].get(); if (show_camera_feed && sidebar_camera != nullptr && state->has_tracker_time) { diff --git a/openpilot/tools/jotpluggler/session.cc b/openpilot/tools/jotpluggler/session.cc index 71a62fc7e6..fbadcc03f1 100644 --- a/openpilot/tools/jotpluggler/session.cc +++ b/openpilot/tools/jotpluggler/session.cc @@ -31,6 +31,9 @@ void apply_route_data(AppSession *session, UiState *state, RouteData route_data) } else if (session->route_name.empty() && session->data_mode == SessionDataMode::Route) { session->route_id = {}; } + state->plot_view_history.clear(); + state->last_plot_view_id = 0; + state->restore_plot_view.reset(); session->route_data = std::move(route_data); rebuild_route_index(session); rebuild_browser_nodes(session, state); From 948ad05c6ceb7eed4cd18eb75f048ddd3230e3c1 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 8 Oct 2026 19:18:57 -0700 Subject: [PATCH 210/270] chestnut: fix offroad alert (#39056) chestnut: fix offroad alert --- openpilot/selfdrive/selfdrived/alerts_offroad.json | 2 +- openpilot/selfdrive/selfdrived/selfdrived.py | 3 ++- openpilot/selfdrive/ui/ui_state.py | 6 +++++- openpilot/system/hardware/chestnut/status.py | 3 +++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/openpilot/selfdrive/selfdrived/alerts_offroad.json b/openpilot/selfdrive/selfdrived/alerts_offroad.json index 8316d093b0..052cc21f92 100644 --- a/openpilot/selfdrive/selfdrived/alerts_offroad.json +++ b/openpilot/selfdrive/selfdrived/alerts_offroad.json @@ -26,7 +26,7 @@ "severity": 0 }, "Offroad_ChestnutNotDetected": { - "text": "Chestnut not detected. Check USB and 12V connections.", + "text": "Chestnut not detected. Check USB and 12V connections, then restart the car.", "severity": 0 }, "Offroad_ChestnutOverheated": { diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 0a468091a7..52370f8c7f 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -172,6 +172,7 @@ class SelfdriveD: big_failed = big_active is False or model_unavailable or (self.big_model_active and not chestnut_present) if big_failed and not self.big_model_failed: self.events.add(EventName.bigModelFailed) + self.big_model_ready_t = time.monotonic() self.big_model_failed = big_failed # soft disable if the big model fails @@ -453,7 +454,7 @@ class SelfdriveD: # TODO: fix simulator if not SIMULATION or REPLAY: - if self.sm['modelV2'].frameDropPerc > 1: + if self.sm['modelV2'].frameDropPerc > 1 and not big_model_settling: self.events.add(EventName.modeldLagging) # Decrement personality on distance button press diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index 647f977677..d7035dab00 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -14,6 +14,7 @@ from openpilot.system.ui.lib.application import gui_app from openpilot.common.hardware import HARDWARE, PC from openpilot.common.hardware.usb import cable_connected, get_usb_state, is_chestnut_usb_id from openpilot.selfdrive.modeld.helpers import chestnut_compiled +from openpilot.selfdrive.selfdrived.alertmanager import OFFROAD_ALERTS BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50 PARAM_UPDATE_TIME = 1 / 5.0 @@ -90,6 +91,7 @@ class UIState: self.chestnut_compiled: bool = chestnut_compiled() self.chestnut_active: bool | None = None self.chestnut_loading: bool = False + self.chestnut_alert: bool = False self.usb_connected: bool = False self.usb_connected_ts: float | None = None self.usb_disconnected_ts: float | None = None @@ -212,7 +214,8 @@ class UIState: detected = self.sm["deviceState"].chestnutPresent if not self.started: self.chestnut_present = detected - self.chestnut_state = (ChestnutState.READY if detected and self.chestnut_compiled else + self.chestnut_state = (ChestnutState.FAILED if self.chestnut_alert else + ChestnutState.READY if detected and self.chestnut_compiled else ChestnutState.UNCOMPILED if detected else ChestnutState.DISCONNECTED) return @@ -247,6 +250,7 @@ class UIState: self.experimental_mode = self.params.get_bool("ExperimentalMode") self.chestnut_active = self.params.get("ChestnutActive") self.chestnut_loading = self.params.get_bool("ChestnutLoading") + self.chestnut_alert = any(self.params.get(k) is not None for k, a in OFFROAD_ALERTS.items() if k.startswith("Offroad_Chestnut") and a["severity"] >= 0) now = time.monotonic() if cable_connected(): self.usb_disconnected_ts = None diff --git a/openpilot/system/hardware/chestnut/status.py b/openpilot/system/hardware/chestnut/status.py index 04a3dff821..ec54f768db 100644 --- a/openpilot/system/hardware/chestnut/status.py +++ b/openpilot/system/hardware/chestnut/status.py @@ -53,6 +53,8 @@ class ChestnutStatus: if not offroad and self.usb_seen and len(devices) != 1: self.usb_failed = True + if offroad and len(devices) == 1: + self.usb_failed = False if not offroad and state is not None: powered = state.supplyVoltage >= CHESTNUT_POWERED_VOLTAGE @@ -74,6 +76,7 @@ class ChestnutStatus: self.power_unavailable = False self.power_lost = False self.power_restored = False + self.model_failed = False if state is not None: gpu_limit = GPU_TEMP_LIMIT - (TEMP_HYSTERESIS if self.overheated else 0.) From 9b585d58f54d0129a1dfa434b59506a29c011336 Mon Sep 17 00:00:00 2001 From: stef <19478336+stefpi@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:19:05 -0700 Subject: [PATCH 211/270] network: force metered on comma prime webbing sim (#38756) * force metered on comma prime webbing sim * rearrange --------- Co-authored-by: Daniel Koepping --- openpilot/common/esim/base.py | 4 +++- openpilot/common/hardware/comma/hardware.py | 3 +++ openpilot/common/hardware/comma/modem.py | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/openpilot/common/esim/base.py b/openpilot/common/esim/base.py index 472b523e2c..547e36b12f 100644 --- a/openpilot/common/esim/base.py +++ b/openpilot/common/esim/base.py @@ -1,6 +1,8 @@ from abc import ABC, abstractmethod from dataclasses import dataclass +WEBBING_ICCID_PREFIX = "8985235" + class LPAError(RuntimeError): pass @@ -19,7 +21,7 @@ class Profile: @property def is_comma(self) -> bool: - return self.provider == 'Webbing' and self.iccid.startswith('8985235') + return self.provider == 'Webbing' and self.iccid.startswith(WEBBING_ICCID_PREFIX) @property def display_name(self) -> str: diff --git a/openpilot/common/hardware/comma/hardware.py b/openpilot/common/hardware/comma/hardware.py index effb4d9dbe..7de6e704c2 100644 --- a/openpilot/common/hardware/comma/hardware.py +++ b/openpilot/common/hardware/comma/hardware.py @@ -8,6 +8,7 @@ from functools import cached_property, lru_cache from pathlib import Path from openpilot.cereal import log +from openpilot.common.esim.base import WEBBING_ICCID_PREFIX from openpilot.common.utils import sudo_read, sudo_write from openpilot.common.gpio import gpio_set, gpio_init, get_irqs_for_action from openpilot.common.esim.base import LPABase @@ -207,6 +208,8 @@ class HardwareComma(HardwareBase): def get_network_metered(self, network_type) -> bool: if network_type in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G): + if self.get_modem_state().get("iccid", "").startswith(WEBBING_ICCID_PREFIX): + return True from openpilot.common.params import Params return Params().get_bool("GsmMetered") try: diff --git a/openpilot/common/hardware/comma/modem.py b/openpilot/common/hardware/comma/modem.py index 901efae2b1..f1b79862b1 100755 --- a/openpilot/common/hardware/comma/modem.py +++ b/openpilot/common/hardware/comma/modem.py @@ -17,6 +17,8 @@ from ipaddress import IPv4Address, AddressValueError from enum import Enum +from openpilot.common.esim.base import WEBBING_ICCID_PREFIX + logging.basicConfig( level=logging.INFO, format="%(asctime)s.%(msecs)03d %(levelname)-7s modem: %(message)s", @@ -44,7 +46,6 @@ NETWORK_TYPE = {0: "gsm", 1: "gsm", 3: "gsm", 8: "gsm", 11: "nr", 12: "nr", 13: "nr"} DIAL_CID = 1 -WEBBING_ICCID_PREFIX = "8985235" PPPD_CMD = [ "sudo", "pppd", PPP_PORT, "460800", "noauth", "nodetach", "noipdefault", "usepeerdns", From d05c2d971b15ef29a329a6fcce46cc1e64e10778 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 8 Oct 2026 19:26:56 -0700 Subject: [PATCH 212/270] bump tinygrad (#39054) * bump tinygrad * Revert "modeld: drop chestnut on fallback (#39044)" * rebuild big models --- openpilot/selfdrive/modeld/modeld.py | 13 ++----------- .../modeld/models/big_driving_tinygrad.pkl | 4 ++-- .../models/big_driving_warp_1344x760_tinygrad.pkl | 4 ++-- .../models/big_driving_warp_1928x1208_tinygrad.pkl | 4 ++-- tinygrad_repo | 2 +- 5 files changed, 9 insertions(+), 18 deletions(-) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index dbb64ec281..86e5839bc5 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -77,12 +77,6 @@ def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log. shouldStop=bool(stop)) -def drop_chestnut() -> None: - if "AMD" in Device._opened_devices: - for d in Device._opened_devices: - Device[d].pending.pop(Device["AMD"], None) - - class ChestnutGpuState: # GPU metrics require modeld's GPU context def __init__(self, pm: PubMaster, big: bool): @@ -140,8 +134,8 @@ class FrameMeta: def input_view(buffer: Buffer, shape: tuple[int, ...], dtype: DType, offset: int) -> Tensor: - view = buffer.view(math.prod(shape), dtype, offset).ensure_allocated() - return Tensor(UOp.from_buffer(view)).reshape(shape) + view = buffer.view(math.prod(shape) * dtype.itemsize, offset).ensure_allocated() + return Tensor(UOp.from_buffer(view, dtype)).reshape(shape) class ModelState: @@ -287,8 +281,6 @@ def main(demo=False): loader.join(BIG_MODEL_TIMEOUT) model = big_model params.put_bool("ChestnutActive", model is not None) - if model is None: - drop_chestnut() small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or CHESTNUT else None if model is None: @@ -421,7 +413,6 @@ def main(demo=False): raise # fallback to small model cloudlog.exception("big model failed, fall back to small") - drop_chestnut() params.put_bool("ChestnutActive", False) model = small_model if chestnut_state is not None: diff --git a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl index 7925e6d828..48a94630eb 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cc75858d5e007b725b6264e194da4321d7874a212dc631b8f40a215dfd172cd2 -size 799992286 +oid sha256:51420a138242fe57dc4c2d2530ae8edb74afbd46f3282be8995ee3b603b79c04 +size 799942038 diff --git a/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl index baa4f72f41..9be88a8d5d 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e80410f65706472e72150cfdcb0087595782e8aa366149286672511a6224ef54 -size 864120 +oid sha256:17e31388ad4412fb9045ced9280fc471a9933be2a25689156652a613d753954c +size 844637 diff --git a/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl index 242cc0fb9a..27ce69c82e 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:54dff4bc4a64a8615823bb4906429a9db30f4b9fca9b8a56e9c84100227608bb -size 863864 +oid sha256:e761b629b569c7b08a8f0ca696bcf5143c055526e148395bdc189c6ba1a52fe1 +size 844380 diff --git a/tinygrad_repo b/tinygrad_repo index 9d0446a4ba..d3f09c9bbd 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit 9d0446a4ba8a532c8b674fb6ad795af015cd9dcf +Subproject commit d3f09c9bbd542fbfbe68c8569a1173550c408969 From 043759da3627803304956abbab4d10341cee8328 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 8 Oct 2026 20:38:06 -0700 Subject: [PATCH 213/270] add chestnut model fallback test (#39058) * test_onroad: chestnut usb unplug fallback * test_onroad: fix chestnut unplug test * test_onroad: chestnut fallback * check that small model is running * fix small model check --- openpilot/selfdrive/test/test_onroad.py | 26 ++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/openpilot/selfdrive/test/test_onroad.py b/openpilot/selfdrive/test/test_onroad.py index 0b64b3fb39..e67b7ebb1d 100755 --- a/openpilot/selfdrive/test/test_onroad.py +++ b/openpilot/selfdrive/test/test_onroad.py @@ -11,7 +11,7 @@ import numpy as np from collections import Counter, defaultdict from pathlib import Path from openpilot.common.test import OpenpilotTestCase -from openpilot.common.utils import tabulate +from openpilot.common.utils import sudo_write, tabulate from openpilot.cereal import log import openpilot.cereal.messaging as messaging @@ -23,6 +23,7 @@ from openpilot.selfdrive.selfdrived.events import EVENTS, ET from openpilot.selfdrive.test.helpers import set_params_enabled, release_only, processes_context, log_collector from openpilot.common.hardware import HARDWARE from openpilot.common.hardware.hw import Paths +from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH, read from openpilot.common.mock import mock_messages from opendbc.car.car_helpers import get_demo_car_params from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled @@ -466,8 +467,10 @@ class TestChestnutOnroad(OpenpilotTestCase): COMMA_HARDWARE_TEST = True @mock_messages(['deviceMotion']) - def test_camera_models(self, subtests): + def test_big_model(self, subtests): assert chestnut_present() and chestnut_compiled() + bus = next(read(d / "busnum") for d in USB_DEVICES_PATH.glob("*") if read(d / "product") == CHESTNUT_USB_PRODUCT) + authorized = str(USB_DEVICES_PATH / f"usb{bus}" / "authorized") Params().put("CarParams", get_demo_car_params().to_bytes(), block=True) services = ['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState', 'modelV2', 'driverStateV2'] sm = messaging.SubMaster(services) @@ -480,9 +483,23 @@ class TestChestnutOnroad(OpenpilotTestCase): while not all(sm.seen.values()) or not sm.valid['modelV2']: pm.send('deviceState', device_state_bytes) sm.update(1000) + # test big model and camera timings with log_collector(services) as (logs, _): time.sleep(TEST_DURATION) + # test small model fallback by deauthorizing the usb bus + try: + sudo_write("0", authorized) + with Timeout(10, "modeld didn't fall back to the small model"): + while sm['modelV2'].big: + sm.update(1000) + finally: + sudo_write("1", authorized) + + # test small model keeps running + with log_collector(['modelV2']) as (small_model_logs, _): + time.sleep(3) + msgs = {s: [m for m in logs if m.which() == s] for s in services} for service, messages in msgs.items(): with subtests.test(service=service): @@ -495,8 +512,11 @@ class TestChestnutOnroad(OpenpilotTestCase): camera_frames = {m.narrowRoadCameraState.frameId for m in msgs['narrowRoadCameraState']} model_frames = {m.modelV2.frameId for m in msgs['modelV2']} assert len(camera_frames & model_frames) >= TEST_DURATION * SERVICE_LIST['modelV2'].frequency * 0.9 - assert all(m.modelV2.big for m in msgs['modelV2']), "Chestnut fell back to the small model" + assert all(m.modelV2.big for m in msgs['modelV2']), "chestnut fell back to the small model" assert all(np.isfinite(m.modelV2.position.x).all() for m in msgs['modelV2']) + assert Params().get("ChestnutActive") is False + expected = 3 * SERVICE_LIST['modelV2'].frequency + assert np.isclose(len(small_model_logs), expected, rtol=0.05, atol=2), f"small model: expected {expected}, got {len(small_model_logs)}" if __name__ == "__main__": From 0e5718116af4e1cfc004ace0aa22b0f19a5b1635 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Thu, 8 Oct 2026 20:38:08 -0700 Subject: [PATCH 214/270] bump opendbc (#39057) --- opendbc_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendbc_repo b/opendbc_repo index 35f7e08134..229dc7062d 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 35f7e0813462607ef1d703e52313e7571e31405d +Subproject commit 229dc7062d8986b4f954c7c97875b4ffd0044d12 From 655bfdebc72ac13f66193cfc05eb2bc3a84c3a0e Mon Sep 17 00:00:00 2001 From: YassineYousfi Date: Thu, 8 Oct 2026 21:39:24 -0700 Subject: [PATCH 215/270] model replay pr bot: show both (#39055) * model replay pr bot: show both * jenkins --- .../selfdrive/test/process_replay/model_replay.py | 13 +++++++------ openpilot/tools/lib/github_utils.py | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/openpilot/selfdrive/test/process_replay/model_replay.py b/openpilot/selfdrive/test/process_replay/model_replay.py index a4f9fcfb2b..8f43f4066b 100755 --- a/openpilot/selfdrive/test/process_replay/model_replay.py +++ b/openpilot/selfdrive/test/process_replay/model_replay.py @@ -85,7 +85,7 @@ def generate_report(proposed, master, tmp, commit): ], "driverStateV2") return [plot(map(v[0], get_event(proposed, event)), \ - map(v[0], get_event(master, event)), f"{v[1]}_{commit[:7]}", tmp) \ + map(v[0], get_event(master, event)), f"{v[1]}_{'chestnut' if CHESTNUT else 'tici'}_{commit[:7]}", tmp) \ for v,event in ([*ModelV2_Plots] + [*DriverStateV2_Plots])] def create_table(title, files, link, open_table=False): @@ -105,7 +105,8 @@ def create_table(title, files, link, open_table=False): def comment_replay_report(proposed, master, full_logs): with tempfile.TemporaryDirectory() as tmp: PR_BRANCH = os.getenv("GIT_BRANCH","") - DATA_BUCKET = f"model_replay_{PR_BRANCH}" + model_type = "chestnut" if CHESTNUT else "tici" + DATA_BUCKET = f"model_replay_{PR_BRANCH}_{model_type}" try: GITHUB.get_pr_number(PR_BRANCH) @@ -126,8 +127,9 @@ def comment_replay_report(proposed, master, full_logs): link = GITHUB.get_bucket_link(DATA_BUCKET) diff_plots = create_table("Model Replay Differences", diff_files, link, open_table=True) all_plots = create_table("All Model Replay Plots", files, link) - comment = f"ref for commit {commit}: {link}/{log_name}" + diff_plots + all_plots - GITHUB.comment_on_pr(comment, PR_BRANCH, "commaci-public", True) + model_title = "Big model (Chestnut)" if CHESTNUT else "Small model" + comment = f"

Model Replay: {model_title}

ref for commit {commit}: {link}/{log_name}" + diff_plots + all_plots + GITHUB.comment_on_pr(comment, PR_BRANCH, "commaci-public", True, comment_marker=f"_model_{model_type}_") def trim_logs(logs, start_frame, end_frame, frs_types, include_all_types): all_msgs = [] @@ -290,8 +292,7 @@ if __name__ == "__main__": diff_short, diff_long, failed = format_diff(results, log_paths, 'master') if "CI" in os.environ: - if not CHESTNUT: - comment_replay_report(log_msgs, cmp_log, log_msgs) + comment_replay_report(log_msgs, cmp_log, log_msgs) failed = False print(diff_long) print('-------------\n'*5) diff --git a/openpilot/tools/lib/github_utils.py b/openpilot/tools/lib/github_utils.py index 6a443b4155..1e473398c3 100644 --- a/openpilot/tools/lib/github_utils.py +++ b/openpilot/tools/lib/github_utils.py @@ -84,13 +84,13 @@ class GithubUtils: def get_bucket_link(self, bucket): return f'https://raw.githubusercontent.com/{self.OWNER}/{self.DATA_REPO}/refs/heads/{bucket}' - def comment_on_pr(self, comment, pr_branch, commenter="", overwrite=False): + def comment_on_pr(self, comment, pr_branch, commenter="", overwrite=False, comment_marker=""): pr_number = self.get_pr_number(pr_branch) data = f'{{"body": "{comment}"}}' github_path = f'issues/{pr_number}/comments' if overwrite: r = self.api_call(github_path) - comments = [x['id'] for x in r.json() if x['user']['login'] == commenter] + comments = [x['id'] for x in r.json() if x['user']['login'] == commenter and (not comment_marker or comment_marker in x['body'])] if comments: github_path = f'issues/comments/{comments[0]}' self.api_call(github_path, data=data, method=HTTPMethod.PATCH) From 263e8d1966633f0af9172f7f62870b78da3dcb31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Fri, 9 Oct 2026 15:32:15 -0700 Subject: [PATCH 216/270] longitudinal planner: set A_CRUISE_MIN to -0.5 (#39060) --- openpilot/selfdrive/controls/lib/longitudinal_planner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/controls/lib/longitudinal_planner.py b/openpilot/selfdrive/controls/lib/longitudinal_planner.py index d453b677f8..125b4378a3 100755 --- a/openpilot/selfdrive/controls/lib/longitudinal_planner.py +++ b/openpilot/selfdrive/controls/lib/longitudinal_planner.py @@ -19,7 +19,7 @@ from openpilot.common.swaglog import cloudlog A_CRUISE_MAX_VALS = [1.6, 1.2, 0.8, 0.6] A_CRUISE_MAX_BP = [0., 10.0, 25., 40.] J_CRUISE_VALS = [1.6, 1.2, 0.8, 0.6] -A_CRUISE_MIN = -1.2 +A_CRUISE_MIN = -0.5 CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N] ALLOW_THROTTLE_THRESHOLD = 0.4 MIN_ALLOW_THROTTLE_SPEED = 2.5 From b9c815d56a2827796ad73ca8f09d186243dd8c17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20R=C4=85czy?= Date: Fri, 9 Oct 2026 18:42:59 -0700 Subject: [PATCH 217/270] tools: use log migration in cpp tools (#38571) * Universal PyProcess module runner. Migration cli * print to stderr * Migration in jotpluggler * Do - * Improvements * Explicit stdout * Existing master pyprocess changes * PyTools * Fix runtime warning --- .../test/process_replay/migration.py | 19 ++- .../test/process_replay/process_replay.py | 3 +- openpilot/tools/cabana/routes.cc | 6 +- openpilot/tools/cabana/routes.h | 2 +- .../tools/cabana/ui/dialogs/routesdialog.cc | 4 +- openpilot/tools/cabana/ui/mainwin.cc | 2 +- openpilot/tools/jotpluggler/app.cc | 1 + openpilot/tools/jotpluggler/app.h | 5 +- openpilot/tools/jotpluggler/layout.cc | 4 +- openpilot/tools/jotpluggler/main.cc | 3 + openpilot/tools/jotpluggler/runtime.cc | 10 +- openpilot/tools/jotpluggler/sketch_layout.cc | 18 ++- openpilot/tools/replay/SConscript | 2 +- openpilot/tools/replay/consoleui.cc | 2 +- openpilot/tools/replay/filereader.cc | 6 +- openpilot/tools/replay/framereader.cc | 4 +- openpilot/tools/replay/logreader.cc | 2 +- .../{py_downloader.cc => py_process.cc} | 117 ++++-------------- openpilot/tools/replay/py_process.h | 21 ++++ openpilot/tools/replay/py_tools.cc | 102 +++++++++++++++ .../replay/{py_downloader.h => py_tools.h} | 7 +- openpilot/tools/replay/route.cc | 4 +- 22 files changed, 215 insertions(+), 129 deletions(-) rename openpilot/tools/replay/{py_downloader.cc => py_process.cc} (55%) create mode 100644 openpilot/tools/replay/py_process.h create mode 100644 openpilot/tools/replay/py_tools.cc rename openpilot/tools/replay/{py_downloader.h => py_tools.h} (86%) diff --git a/openpilot/selfdrive/test/process_replay/migration.py b/openpilot/selfdrive/test/process_replay/migration.py index ee1fb3b943..bd6d2ad546 100644 --- a/openpilot/selfdrive/test/process_replay/migration.py +++ b/openpilot/selfdrive/test/process_replay/migration.py @@ -4,6 +4,8 @@ from typing import cast import capnp import functools import traceback +import sys +import argparse from openpilot.cereal import messaging, log from opendbc.car.structs import car @@ -17,7 +19,7 @@ from openpilot.selfdrive.modeld.fill_model_msg import fill_xyz_poly, fill_lane_l from openpilot.selfdrive.test.process_replay.vision_meta import meta_from_encode_index from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N, get_accel_from_plan, should_stop from openpilot.system.manager.process_config import managed_processes -from openpilot.tools.lib.logreader import LogIterable +from openpilot.tools.lib.logreader import LogIterable, LogReader, save_log MessageWithIndex = tuple[int, capnp.lib.capnp._DynamicStructReader] MigrationOps = tuple[list[tuple[int, capnp.lib.capnp._DynamicStructReader]], list[capnp.lib.capnp._DynamicStructReader], list[int]] @@ -387,7 +389,7 @@ def migrate_cameraStates(msgs): encode_id = frame_to_encode_id[msg.which()].get(camera_state.frameId) if encode_id is None: - print(f"Missing encoded frame for camera feed {msg.which()} with frameId: {camera_state.frameId}") + print(f"Missing encoded frame for camera feed {msg.which()} with frameId: {camera_state.frameId}", file=sys.stderr) if len(frame_to_encode_id[msg.which()]) != 0: del_ops.append(index) continue @@ -395,7 +397,7 @@ def migrate_cameraStates(msgs): # fallback mechanism for logs without encodeIdx (e.g. logs from before 2022 with driver recording disabled) # try to fake encode_id by subtracting lowest frameId encode_id = camera_state.frameId - min_frame_id[msg.which()] - print(f"Faking encodeId to {encode_id} for camera feed {msg.which()} with frameId: {camera_state.frameId}") + print(f"Faking encodeId to {encode_id} for camera feed {msg.which()} with frameId: {camera_state.frameId}", file=sys.stderr) new_msg = messaging.new_message(msg.which()) new_camera_state = getattr(new_msg, new_msg.which()) @@ -527,3 +529,14 @@ def migrate_driverMonitoringState(msgs): ops.append((index, as_reader(new_msg))) return ops, [], [] + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description="Migrate logs") + parser.add_argument("input_path", help="Segment identifier or path to file") + parser.add_argument("output_path", help="Path to output file") + args = parser.parse_args() + + mlr = migrate_all(LogReader(args.input_path)) + print(f"Saving migrated log to {args.output_path}", file=sys.stderr) + save_log(args.output_path , mlr) diff --git a/openpilot/selfdrive/test/process_replay/process_replay.py b/openpilot/selfdrive/test/process_replay/process_replay.py index df60d54e88..328b0bbbdd 100755 --- a/openpilot/selfdrive/test/process_replay/process_replay.py +++ b/openpilot/selfdrive/test/process_replay/process_replay.py @@ -27,7 +27,6 @@ from openpilot.common.realtime import DT_CTRL from openpilot.system.camerad.cameras.nv12_info import get_nv12_info from openpilot.system.manager.process_config import managed_processes from openpilot.selfdrive.test.process_replay.vision_meta import meta_from_camera_state, available_streams -from openpilot.selfdrive.test.process_replay.migration import migrate_all from openpilot.selfdrive.test.process_replay.capture import ProcessOutputCapture from openpilot.tools.lib.logreader import LogIterable from openpilot.tools.lib.framereader import FrameReader @@ -637,6 +636,8 @@ def replay_process( else: cfgs = list(cfg) + from openpilot.selfdrive.test.process_replay.migration import migrate_all + all_msgs = migrate_all(lr, manager_states=True, panda_states=any("pandaStates" in cfg.pubs for cfg in cfgs), diff --git a/openpilot/tools/cabana/routes.cc b/openpilot/tools/cabana/routes.cc index 50a1eacf8d..2d3560e176 100644 --- a/openpilot/tools/cabana/routes.cc +++ b/openpilot/tools/cabana/routes.cc @@ -6,7 +6,7 @@ #include #include "json11/json11.hpp" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" namespace routes { @@ -91,7 +91,7 @@ std::vector parseRoutes(const std::string &json, bool preserved) { void fetchDevices(DevicesCallback callback) { std::thread([callback = std::move(callback)]() { - std::string result = PyDownloader::getDevices(); + std::string result = PyTools::getDevices(); auto [success, error_code] = checkApiResponse(result); callback(success ? parseDevices(result) : std::vector{}, success, error_code); }).detach(); @@ -106,7 +106,7 @@ void fetchRoutes(const std::string &dongle_id, int period_days, RoutesCallback c } std::thread([dongle_id, start_ms, end_ms, preserved, callback = std::move(callback)]() { - std::string result = PyDownloader::getDeviceRoutes(dongle_id, start_ms, end_ms, preserved); + std::string result = PyTools::getDeviceRoutes(dongle_id, start_ms, end_ms, preserved); auto [success, error_code] = checkApiResponse(result); callback(success ? parseRoutes(result, preserved) : std::vector{}, success, error_code); }).detach(); diff --git a/openpilot/tools/cabana/routes.h b/openpilot/tools/cabana/routes.h index ef6fc8dee0..2d1cab3dea 100644 --- a/openpilot/tools/cabana/routes.h +++ b/openpilot/tools/cabana/routes.h @@ -21,7 +21,7 @@ struct RouteInfo { using DevicesCallback = std::function devices, bool success, int error_code)>; using RoutesCallback = std::function routes, bool success, int error_code)>; -// Parse a PyDownloader JSON response into (success, error_code). +// Parse a PyTools JSON response into (success, error_code). std::pair checkApiResponse(const std::string &result); int64_t nowUnixMs(); diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc index e669ac5fd5..665edb7c98 100644 --- a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -6,7 +6,7 @@ #include #include "json11/json11.hpp" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" #include "tools/cabana/ui/theme.h" #include "imgui.h" @@ -157,7 +157,7 @@ void RoutesDialog::signIn(const std::string &provider) { s_.auth_error.clear(); auth_abort_ = std::make_shared>(false); std::thread([this, alive = std::weak_ptr(alive_), abort = auth_abort_, provider]() { - const std::string result = PyDownloader::authenticate(provider, abort.get()); + const std::string result = PyTools::authenticate(provider, abort.get()); utils::runOnMainThread(utils::guarded(alive.lock(), [this, abort, result]() { if (*abort) return; auth_abort_.reset(); diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc index 674f2c3aa1..80ae6a3da2 100644 --- a/openpilot/tools/cabana/ui/mainwin.cc +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -25,7 +25,7 @@ #include "tools/cabana/ui/util.h" #include "tools/cabana/utils/export.h" #include "tools/cabana/utils/util.h" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" #include "tools/replay/util.h" namespace { diff --git a/openpilot/tools/jotpluggler/app.cc b/openpilot/tools/jotpluggler/app.cc index 5cdcff5ed2..c7a74a0ecb 100644 --- a/openpilot/tools/jotpluggler/app.cc +++ b/openpilot/tools/jotpluggler/app.cc @@ -1821,6 +1821,7 @@ int run(const Options &options) { .route_name = options.route_name, .data_dir = options.data_dir, .dbc_override = {}, + .migrate = options.migrate, .stream_source = StreamSourceConfig{.kind = is_local_stream_address(options.stream_address) ? StreamSourceKind::CerealLocal : StreamSourceKind::CerealRemote, diff --git a/openpilot/tools/jotpluggler/app.h b/openpilot/tools/jotpluggler/app.h index dd6c81eb23..e8fc4563e0 100644 --- a/openpilot/tools/jotpluggler/app.h +++ b/openpilot/tools/jotpluggler/app.h @@ -34,6 +34,7 @@ struct Options { bool show = false; bool sync_load = false; bool stream = false; + bool migrate = true; double stream_buffer_seconds = 30.0; }; @@ -435,6 +436,7 @@ std::vector decode_can_messages(const std::vector & RouteData load_route_data(const std::string &route_name, const std::string &data_dir = {}, const std::string &dbc_name = {}, + bool migrate = true, const RouteLoadProgressCallback &progress = {}); RouteIdentifier parse_route_identifier(std::string_view route_name); void rebuild_gps_trace(RouteData *route_data); @@ -508,6 +510,7 @@ struct AppSession { std::string route_name; std::string data_dir; std::string dbc_override; + bool migrate = true; StreamSourceConfig stream_source; double stream_buffer_seconds = 30.0; SessionDataMode data_mode = SessionDataMode::Route; @@ -885,7 +888,7 @@ public: AsyncRouteLoader(const AsyncRouteLoader &) = delete; AsyncRouteLoader &operator=(const AsyncRouteLoader &) = delete; - void start(const std::string &route_name, const std::string &data_dir, const std::string &dbc_name); + void start(const std::string &route_name, const std::string &data_dir, const std::string &dbc_name, bool migrate); RouteLoadSnapshot snapshot() const; bool consume(RouteData *route_data, std::string *error_text); diff --git a/openpilot/tools/jotpluggler/layout.cc b/openpilot/tools/jotpluggler/layout.cc index 6bc3a6168a..091aea5abe 100644 --- a/openpilot/tools/jotpluggler/layout.cc +++ b/openpilot/tools/jotpluggler/layout.cc @@ -547,7 +547,7 @@ bool save_layout(AppSession *session, UiState *state, const std::string &layout_ void rebuild_session_route_data(AppSession *session, UiState *state, const RouteLoadProgressCallback &progress) { - apply_route_data(session, state, load_route_data(session->route_name, session->data_dir, session->dbc_override, progress)); + apply_route_data(session, state, load_route_data(session->route_name, session->data_dir, session->dbc_override, session->migrate, progress)); } void stop_stream_session(AppSession *session, UiState *state, bool preserve_data) { @@ -627,7 +627,7 @@ void start_async_route_load(AppSession *session, UiState *state) { return; } apply_route_data(session, state, RouteData{}); - session->route_loader->start(session->route_name, session->data_dir, session->dbc_override); + session->route_loader->start(session->route_name, session->data_dir, session->dbc_override, session->migrate); state->status_text = session->route_name.empty() ? "Ready" : "Loading route " + session->route_name; } diff --git a/openpilot/tools/jotpluggler/main.cc b/openpilot/tools/jotpluggler/main.cc index 22bc29664c..89cb8989e8 100644 --- a/openpilot/tools/jotpluggler/main.cc +++ b/openpilot/tools/jotpluggler/main.cc @@ -22,6 +22,7 @@ void print_usage(const char *argv0) { << " --output \n" << " --show\n" << " --sync-load\n" + << " --no-migration\n" << "\n" << "Examples:\n" << " " << argv0 << "\n" @@ -94,6 +95,8 @@ int main(int argc, char *argv[]) { options.show = true; } else if (arg == "--sync-load") { options.sync_load = true; + } else if (arg == "--no-migration") { + options.migrate = false; } else if (arg == "--help" || arg == "-h") { print_usage(argv[0]); return 0; diff --git a/openpilot/tools/jotpluggler/runtime.cc b/openpilot/tools/jotpluggler/runtime.cc index dfbb5474c9..beac450b87 100644 --- a/openpilot/tools/jotpluggler/runtime.cc +++ b/openpilot/tools/jotpluggler/runtime.cc @@ -261,13 +261,14 @@ struct AsyncRouteLoader::Impl { join(); } - void start(const std::string &route_name_value, const std::string &data_dir_value, const std::string &dbc_name_value) { + void start(const std::string &route_name_value, const std::string &data_dir_value, const std::string &dbc_name_value, bool migrate_value) { join(); { std::lock_guard lock(mutex); route_name = route_name_value; data_dir = data_dir_value; dbc_name = dbc_name_value; + migrate = migrate_value; result.reset(); error_text.clear(); } @@ -283,7 +284,7 @@ struct AsyncRouteLoader::Impl { worker = std::thread([this]() { try { - RouteData route_data = load_route_data(route_name, data_dir, dbc_name, [this](const RouteLoadProgress &progress) { + RouteData route_data = load_route_data(route_name, data_dir, dbc_name, migrate, [this](const RouteLoadProgress &progress) { total_segments.store(progress.total_segments > 0 ? progress.total_segments : progress.segment_count); segments_downloaded.store(progress.segments_downloaded); segments_parsed.store(progress.segments_parsed); @@ -347,6 +348,7 @@ struct AsyncRouteLoader::Impl { std::string route_name; std::string data_dir; std::string dbc_name; + bool migrate = true; std::string error_text; std::atomic active{false}; std::atomic completed{false}; @@ -362,8 +364,8 @@ AsyncRouteLoader::AsyncRouteLoader(bool enable_terminal_progress) AsyncRouteLoader::~AsyncRouteLoader() = default; -void AsyncRouteLoader::start(const std::string &route_name, const std::string &data_dir, const std::string &dbc_name) { - impl_->start(route_name, data_dir, dbc_name); +void AsyncRouteLoader::start(const std::string &route_name, const std::string &data_dir, const std::string &dbc_name, bool migrate) { + impl_->start(route_name, data_dir, dbc_name, migrate); } RouteLoadSnapshot AsyncRouteLoader::snapshot() const { diff --git a/openpilot/tools/jotpluggler/sketch_layout.cc b/openpilot/tools/jotpluggler/sketch_layout.cc index bca20ec20c..8fb72fbe1e 100644 --- a/openpilot/tools/jotpluggler/sketch_layout.cc +++ b/openpilot/tools/jotpluggler/sketch_layout.cc @@ -25,7 +25,7 @@ #include "common/util.h" #include "json11/json11.hpp" #include "tools/replay/logreader.h" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" namespace fs = std::filesystem; @@ -331,7 +331,7 @@ std::map load_segments_from_json(const json11::Json &json) { std::map load_segments_from_server(const RouteSelection &route) { const std::string selector = route.selector == LogSelector::RLog ? "r" : route.selector == LogSelector::QLog ? "q" : "a"; - const std::string result = PyDownloader::resolveRouteFiles(route.canonical_name, route.begin_segment, route.end_segment, selector); + const std::string result = PyTools::resolveRouteFiles(route.canonical_name, route.begin_segment, route.end_segment, selector); if (result.empty()) throw std::runtime_error("Failed to fetch route files for " + route.canonical_name); std::string parse_error; @@ -1577,6 +1577,7 @@ LoadedRouteArtifacts load_route_series_parallel( const dbc::Database *can_dbc, LogSelector selector, bool skip_raw_can, + bool migrate, LoadStats *stats) { struct SegmentResult { SeriesAccumulator series; @@ -1621,8 +1622,16 @@ LoadedRouteArtifacts load_route_series_parallel( continue; } + std::string migrated_data; + if (migrate) { + migrated_data = PyTools::migrateLog(log_path); + } + LogReader reader; - if (!reader.load(log_path, nullptr, true)) { + const bool loaded = !migrated_data.empty() + ? reader.load(migrated_data.data(), migrated_data.size()) + : reader.load(log_path, nullptr, true); + if (!loaded) { segment_stats.failed = true; std::lock_guard lock(error_mutex); if (first_error.empty()) { @@ -1889,6 +1898,7 @@ SketchLayout load_sketch_layout(const fs::path &layout_path) { RouteData load_route_data(const std::string &route_name, const std::string &data_dir, const std::string &dbc_name, + bool migrate, const RouteLoadProgressCallback &progress) { if (route_name.empty()) return RouteData{}; @@ -1916,7 +1926,7 @@ RouteData load_route_data(const std::string &route_name, const SchemaIndex &schema = SchemaIndex::instance(); LoadedRouteArtifacts artifacts = load_route_series_parallel(segments, schema, can_dbc ? &*can_dbc : nullptr, - route.selector, can_dbc.has_value(), &stats); + route.selector, can_dbc.has_value(), migrate, &stats); RouteData route_data = build_route_data(std::move(artifacts.series), std::move(artifacts.can_messages), std::move(artifacts.logs), diff --git a/openpilot/tools/replay/SConscript b/openpilot/tools/replay/SConscript index f6c45b3792..8358d28cba 100644 --- a/openpilot/tools/replay/SConscript +++ b/openpilot/tools/replay/SConscript @@ -7,7 +7,7 @@ base_frameworks = ['VideoToolbox', 'CoreMedia', 'CoreFoundation', 'CoreVideo'] i base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] replay_lib_src = ["replay.cc", "consoleui.cc", "camera.cc", "filereader.cc", "logreader.cc", "framereader.cc", - "route.cc", "util.cc", "seg_mgr.cc", "timeline.cc", "py_downloader.cc"] + "route.cc", "util.cc", "seg_mgr.cc", "timeline.cc", "py_process.cc", "py_tools.cc"] if arch != "Darwin": replay_lib_src.append("#openpilot/system/loggerd/encoder/v4l_decoder.cc") replay_lib = replay_env.Library("replay", replay_lib_src, LIBS=base_libs, FRAMEWORKS=base_frameworks) diff --git a/openpilot/tools/replay/consoleui.cc b/openpilot/tools/replay/consoleui.cc index aa58cc959d..0da8e033b6 100644 --- a/openpilot/tools/replay/consoleui.cc +++ b/openpilot/tools/replay/consoleui.cc @@ -9,7 +9,7 @@ #include "common/ratekeeper.h" #include "common/util.h" #include "common/version.h" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" namespace { diff --git a/openpilot/tools/replay/filereader.cc b/openpilot/tools/replay/filereader.cc index 6ad56bbfc6..f1b506c2b4 100644 --- a/openpilot/tools/replay/filereader.cc +++ b/openpilot/tools/replay/filereader.cc @@ -4,12 +4,12 @@ #include #include "common/util.h" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" std::string FileReader::read(const std::string &file, std::atomic *abort) { const bool is_remote = (file.find("https://") == 0) || (file.find("http://") == 0); if (is_remote) { - std::string local_path = PyDownloader::download(file, cache_to_local_, abort); + std::string local_path = PyTools::download(file, cache_to_local_, abort); if (local_path.empty()) return {}; return util::read_file(local_path); } @@ -19,7 +19,7 @@ std::string FileReader::read(const std::string &file, std::atomic *abort) const std::string magic(header, stream.gcount()); if (util::ends_with(file, ".bz2") || util::ends_with(file, ".zst") || util::starts_with(magic, "BZh") || magic == "\x28\xB5\x2F\xFD") { - std::string local_path = PyDownloader::decompress(file, abort); + std::string local_path = PyTools::decompress(file, abort); if (local_path.empty()) return {}; std::string data = util::read_file(local_path); unlink(local_path.c_str()); diff --git a/openpilot/tools/replay/framereader.cc b/openpilot/tools/replay/framereader.cc index 19e5fa0d0f..b475e297b3 100644 --- a/openpilot/tools/replay/framereader.cc +++ b/openpilot/tools/replay/framereader.cc @@ -7,7 +7,7 @@ #include "common/util.h" #include "common/yuv.h" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" #include "tools/replay/util.h" #include "common/hardware/hw.h" @@ -75,7 +75,7 @@ FrameReader::~FrameReader() { bool FrameReader::load(CameraType type, const std::string &url, bool no_hw_decoder, std::atomic *abort, bool local_cache) { std::string local_file_path; if (url.find("https://") == 0 || url.find("http://") == 0) { - local_file_path = PyDownloader::download(url, local_cache, abort); + local_file_path = PyTools::download(url, local_cache, abort); if (local_file_path.empty()) return false; } else { local_file_path = url; diff --git a/openpilot/tools/replay/logreader.cc b/openpilot/tools/replay/logreader.cc index 315aed9741..a626d4d442 100644 --- a/openpilot/tools/replay/logreader.cc +++ b/openpilot/tools/replay/logreader.cc @@ -4,7 +4,7 @@ #include #include #include "tools/replay/filereader.h" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" #include "tools/replay/util.h" #include "common/util.h" diff --git a/openpilot/tools/replay/py_downloader.cc b/openpilot/tools/replay/py_process.cc similarity index 55% rename from openpilot/tools/replay/py_downloader.cc rename to openpilot/tools/replay/py_process.cc index 9d4ed04122..1f46dbf384 100644 --- a/openpilot/tools/replay/py_downloader.cc +++ b/openpilot/tools/replay/py_process.cc @@ -1,10 +1,9 @@ -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_process.h" #include -#include #include #include -#include +#include #include #ifdef __APPLE__ #include @@ -16,29 +15,15 @@ #include "tools/replay/util.h" -namespace { +namespace PyProcess { -constexpr const char *AUTH_MODULE = "openpilot.tools.lib.auth"; -constexpr const char *DOWNLOADER_MODULE = "openpilot.tools.lib.file_downloader"; - -static std::mutex handler_mutex; -static DownloadProgressHandler progress_handler = nullptr; - -void reportProgress(const char *line) { - uint64_t cur = 0, total = 0; - if (sscanf(line, "PROGRESS:%llu:%llu", (unsigned long long *)&cur, (unsigned long long *)&total) != 2) return; - std::lock_guard lk(handler_mutex); - if (progress_handler && total > 0) progress_handler(cur, total, true); -} - -// Run a Python command and capture stdout. Stderr is scanned for PROGRESS lines and otherwise passed -// through to the parent's stderr. Returns stdout content. If abort is signaled, kills the child process. -std::string runPython(const char *module, const std::vector &args, std::atomic *abort = nullptr) { +std::string runModule(const std::string &module, const std::vector &args, + std::atomic *abort, bool trim, const StderrLineCallback &stderr_line_cb) { // Build argv for the Python module std::vector argv; argv.push_back("python3"); argv.push_back("-m"); - argv.push_back(module); + argv.push_back(module.c_str()); for (const auto &a : args) { argv.push_back(a.c_str()); } @@ -56,17 +41,17 @@ std::string runPython(const char *module, const std::vector &args, }; int stdout_pipe[2], stderr_pipe[2]; if (open_pipe(stdout_pipe) != 0) { - rWarning("py_downloader: pipe() failed"); + rWarning("py_process: pipe() failed"); return {}; } if (open_pipe(stderr_pipe) != 0) { - rWarning("py_downloader: pipe() failed"); + rWarning("py_process: pipe() failed"); close(stdout_pipe[0]); close(stdout_pipe[1]); return {}; } // Avoid copying the large replay address space and running atfork handlers on - // every segment download: both can stall rendering even from a worker thread. + // every spawn: both can stall rendering even from a worker thread. std::vector environment; #ifdef __APPLE__ char **parent_environment = *_NSGetEnviron(); @@ -103,7 +88,7 @@ std::string runPython(const char *module, const std::vector &args, if (attributes_initialized) posix_spawnattr_destroy(&attributes); if (actions_initialized) posix_spawn_file_actions_destroy(&actions); if (error) { - rWarning("py_downloader: posix_spawnp() failed: %s", strerror(error)); + rWarning("py_process: posix_spawnp() failed: %s", strerror(error)); close(stdout_pipe[0]); close(stdout_pipe[1]); close(stderr_pipe[0]); close(stderr_pipe[1]); return {}; @@ -113,8 +98,8 @@ std::string runPython(const char *module, const std::vector &args, close(stdout_pipe[1]); close(stderr_pipe[1]); - // stderr carries the progress lines, so a thread reads it while the loop below waits on stdout - std::thread stderr_thread([fd = stderr_pipe[0]]() { + // stderr is read in a thread so it can be inspected while the loop below waits on stdout + std::thread stderr_thread([fd = stderr_pipe[0], cb = stderr_line_cb]() { FILE *f = fdopen(fd, "r"); if (!f) { close(fd); @@ -123,8 +108,8 @@ std::string runPython(const char *module, const std::vector &args, char *line = nullptr; size_t cap = 0; while (getline(&line, &cap, f) > 0) { - if (strncmp(line, "PROGRESS:", 9) == 0) { - reportProgress(line); + if (cb) { + cb(line); } else { fputs(line, stderr); } @@ -182,81 +167,23 @@ std::string runPython(const char *module, const std::vector &args, WIFSIGNALED(status); if (failed) { if (expected_sigterm) { - // Route/camera teardown cancels outstanding downloader subprocesses. - // Keep that expected shutdown path quiet. + // Caller signaled abort; expected shutdown path. } else if (WIFEXITED(status) && WEXITSTATUS(status) != 0) { - rWarning("py_downloader: process exited with code %d", WEXITSTATUS(status)); + rWarning("py_process: %s exited with code %d", module.c_str(), WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { - rWarning("py_downloader: process killed by signal %d", WTERMSIG(status)); - } - std::lock_guard lk(handler_mutex); - if (progress_handler) { - progress_handler(0, 0, false); + rWarning("py_process: %s killed by signal %d", module.c_str(), WTERMSIG(status)); } return {}; } // Trim trailing newline - while (!stdout_data.empty() && (stdout_data.back() == '\n' || stdout_data.back() == '\r')) { - stdout_data.pop_back(); + if (trim) { + while (!stdout_data.empty() && (stdout_data.back() == '\n' || stdout_data.back() == '\r')) { + stdout_data.pop_back(); + } } return stdout_data; } -} // namespace - -void installDownloadProgressHandler(DownloadProgressHandler handler) { - std::lock_guard lk(handler_mutex); - progress_handler = handler; -} - -namespace PyDownloader { - -std::string download(const std::string &url, bool use_cache, std::atomic *abort) { - std::vector args = {"download", url}; - if (!use_cache) { - args.push_back("--no-cache"); - } - return runPython(DOWNLOADER_MODULE, args, abort); -} - -std::string decompress(const std::string &path, std::atomic *abort) { - return runPython(DOWNLOADER_MODULE, {"decompress", path}, abort); -} - -std::string getRouteFiles(const std::string &route) { - return runPython(DOWNLOADER_MODULE, {"route-files", route}); -} - -std::string resolveRouteFiles(const std::string &route, int begin, int end, const std::string &selector) { - return runPython(DOWNLOADER_MODULE, {"resolve-route-files", route, "--begin", std::to_string(begin), - "--end", std::to_string(end), "--selector", selector}); -} - -std::string authenticate(const std::string &provider, std::atomic *abort) { - return runPython(AUTH_MODULE, {provider, "--json"}, abort); -} - -std::string getDevices() { - return runPython(DOWNLOADER_MODULE, {"devices"}); -} - -std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms, int64_t end_ms, bool preserved) { - std::vector args = {"device-routes", dongle_id}; - if (preserved) { - args.push_back("--preserved"); - } else { - if (start_ms > 0) { - args.push_back("--start"); - args.push_back(std::to_string(start_ms)); - } - if (end_ms > 0) { - args.push_back("--end"); - args.push_back(std::to_string(end_ms)); - } - } - return runPython(DOWNLOADER_MODULE, args); -} - -} // namespace PyDownloader +} // namespace PyProcess diff --git a/openpilot/tools/replay/py_process.h b/openpilot/tools/replay/py_process.h new file mode 100644 index 0000000000..a0ef491ca9 --- /dev/null +++ b/openpilot/tools/replay/py_process.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include +#include + +namespace PyProcess { + +// Called with each line of the child's stderr (newline-terminated). +using StderrLineCallback = std::function; + +// Run a Python module (`python3 -m `) and capture stdout. +// Stderr lines are passed to stderr_line_cb if set, otherwise through to the parent's stderr. +// If trim is true (default), trailing newlines are removed from the returned stdout content. +// Returns empty string on failure. If abort is signaled, kills the child process. +std::string runModule(const std::string &module, const std::vector &args, + std::atomic *abort = nullptr, bool trim = true, + const StderrLineCallback &stderr_line_cb = nullptr); + +} // namespace PyProcess diff --git a/openpilot/tools/replay/py_tools.cc b/openpilot/tools/replay/py_tools.cc new file mode 100644 index 0000000000..1bf9d1fedc --- /dev/null +++ b/openpilot/tools/replay/py_tools.cc @@ -0,0 +1,102 @@ +#include "tools/replay/py_tools.h" + +#include +#include +#include +#include + +#include "tools/replay/py_process.h" + +namespace { + +constexpr const char *AUTH_MODULE = "openpilot.tools.lib.auth"; +constexpr const char *DOWNLOADER_MODULE = "openpilot.tools.lib.file_downloader"; +constexpr const char *MIGRATION_MODULE = "openpilot.selfdrive.test.process_replay.migration"; + +static std::mutex handler_mutex; +static DownloadProgressHandler progress_handler = nullptr; + +void reportProgress(const char *line) { + uint64_t cur = 0, total = 0; + if (sscanf(line, "PROGRESS:%llu:%llu", (unsigned long long *)&cur, (unsigned long long *)&total) != 2) return; + std::lock_guard lk(handler_mutex); + if (progress_handler && total > 0) progress_handler(cur, total, true); +} + +// Run a Python module, report download progress from PROGRESS lines on stderr, +// and notify the progress handler on failure. +std::string runModuleWithProgress(const std::string &module, const std::vector &args, std::atomic *abort = nullptr) { + std::string result = PyProcess::runModule(module, args, abort, true, [](const char *line) { + if (strncmp(line, "PROGRESS:", 9) == 0) { + reportProgress(line); + } else { + fputs(line, stderr); + } + }); + if (result.empty()) { + std::lock_guard lk(handler_mutex); + if (progress_handler) progress_handler(0, 0, false); + } + return result; +} + +} // namespace + +void installDownloadProgressHandler(DownloadProgressHandler handler) { + std::lock_guard lk(handler_mutex); + progress_handler = handler; +} + +namespace PyTools { + +std::string download(const std::string &url, bool use_cache, std::atomic *abort) { + std::vector args = {"download", url}; + if (!use_cache) { + args.push_back("--no-cache"); + } + return runModuleWithProgress(DOWNLOADER_MODULE, args, abort); +} + +std::string decompress(const std::string &path, std::atomic *abort) { + return runModuleWithProgress(DOWNLOADER_MODULE, {"decompress", path}, abort); +} + +std::string getRouteFiles(const std::string &route) { + return runModuleWithProgress(DOWNLOADER_MODULE, {"route-files", route}); +} + +std::string resolveRouteFiles(const std::string &route, int begin, int end, const std::string &selector) { + return runModuleWithProgress(DOWNLOADER_MODULE, {"resolve-route-files", route, "--begin", std::to_string(begin), + "--end", std::to_string(end), "--selector", selector}); +} + +std::string authenticate(const std::string &provider, std::atomic *abort) { + return runModuleWithProgress(AUTH_MODULE, {provider, "--json"}, abort); +} + +std::string getDevices() { + return runModuleWithProgress(DOWNLOADER_MODULE, {"devices"}); +} + +std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms, int64_t end_ms, bool preserved) { + std::vector args = {"device-routes", dongle_id}; + if (preserved) { + args.push_back("--preserved"); + } else { + if (start_ms > 0) { + args.push_back("--start"); + args.push_back(std::to_string(start_ms)); + } + if (end_ms > 0) { + args.push_back("--end"); + args.push_back(std::to_string(end_ms)); + } + } + return runModuleWithProgress(DOWNLOADER_MODULE, args); +} + +std::string migrateLog(const std::string &log_path) { + return PyProcess::runModule(MIGRATION_MODULE, {log_path, "/dev/stdout"}, nullptr, false); +} + +} // namespace PyTools diff --git a/openpilot/tools/replay/py_downloader.h b/openpilot/tools/replay/py_tools.h similarity index 86% rename from openpilot/tools/replay/py_downloader.h rename to openpilot/tools/replay/py_tools.h index 92bc62140b..9c83b0784a 100644 --- a/openpilot/tools/replay/py_downloader.h +++ b/openpilot/tools/replay/py_tools.h @@ -7,7 +7,7 @@ typedef std::function DownloadProgressHandler; void installDownloadProgressHandler(DownloadProgressHandler handler); -namespace PyDownloader { +namespace PyTools { // Downloads url to local cache, returns local file path. Reports progress via installDownloadProgressHandler. std::string download(const std::string &url, bool use_cache = true, std::atomic *abort = nullptr); @@ -30,4 +30,7 @@ std::string getDevices(); // Returns JSON string of device routes std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms = 0, int64_t end_ms = 0, bool preserved = false); -} // namespace PyDownloader +// Migrates a log segment through the Python migration CLI. Returns the migrated bytes (empty on failure). +std::string migrateLog(const std::string &log_path); + +} // namespace PyTools diff --git a/openpilot/tools/replay/route.cc b/openpilot/tools/replay/route.cc index dce5661b4c..74a65e4e94 100644 --- a/openpilot/tools/replay/route.cc +++ b/openpilot/tools/replay/route.cc @@ -6,7 +6,7 @@ #include "json11/json11.hpp" #include "common/hardware/hw.h" -#include "tools/replay/py_downloader.h" +#include "tools/replay/py_tools.h" #include "tools/replay/replay.h" #include "tools/replay/util.h" @@ -104,7 +104,7 @@ bool Route::loadFromAutoSource() { } bool Route::loadFromServer() { - std::string result = PyDownloader::getRouteFiles(route_.str); + std::string result = PyTools::getRouteFiles(route_.str); if (result.empty()) { err_ = RouteLoadError::NetworkError; rWarning("Failed to fetch route files from server"); From 0679ff36c607612f3ea75bfd488c44390d9cdcb6 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Sat, 10 Oct 2026 09:44:30 -0700 Subject: [PATCH 218/270] chestnut: cut VBUS for fallback test (#39062) cut VBUS for fallback test --- openpilot/selfdrive/test/test_onroad.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/openpilot/selfdrive/test/test_onroad.py b/openpilot/selfdrive/test/test_onroad.py index e67b7ebb1d..e0d55432d2 100755 --- a/openpilot/selfdrive/test/test_onroad.py +++ b/openpilot/selfdrive/test/test_onroad.py @@ -23,7 +23,6 @@ from openpilot.selfdrive.selfdrived.events import EVENTS, ET from openpilot.selfdrive.test.helpers import set_params_enabled, release_only, processes_context, log_collector from openpilot.common.hardware import HARDWARE from openpilot.common.hardware.hw import Paths -from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH, read from openpilot.common.mock import mock_messages from opendbc.car.car_helpers import get_demo_car_params from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled @@ -462,6 +461,8 @@ class TestOnroad(OpenpilotTestCase): f"Not engageable for whole segment:\n- selfdriveState.engageable: {Counter(eng)}\n- No entry events: {no_entries}" +CHESTNUT_VBUS = "/sys/kernel/debug/regulator/smb2-vbus/enable" + @unittest.skipUnless(HARDWARE.get_device_type() == "mici", "requires MICI") class TestChestnutOnroad(OpenpilotTestCase): COMMA_HARDWARE_TEST = True @@ -469,8 +470,6 @@ class TestChestnutOnroad(OpenpilotTestCase): @mock_messages(['deviceMotion']) def test_big_model(self, subtests): assert chestnut_present() and chestnut_compiled() - bus = next(read(d / "busnum") for d in USB_DEVICES_PATH.glob("*") if read(d / "product") == CHESTNUT_USB_PRODUCT) - authorized = str(USB_DEVICES_PATH / f"usb{bus}" / "authorized") Params().put("CarParams", get_demo_car_params().to_bytes(), block=True) services = ['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState', 'modelV2', 'driverStateV2'] sm = messaging.SubMaster(services) @@ -487,19 +486,23 @@ class TestChestnutOnroad(OpenpilotTestCase): with log_collector(services) as (logs, _): time.sleep(TEST_DURATION) - # test small model fallback by deauthorizing the usb bus + # test small model fallback by cutting VBUS which simulates usb disconnect try: - sudo_write("0", authorized) + sudo_write("0", CHESTNUT_VBUS) with Timeout(10, "modeld didn't fall back to the small model"): while sm['modelV2'].big: sm.update(1000) finally: - sudo_write("1", authorized) + sudo_write("1", CHESTNUT_VBUS) # test small model keeps running with log_collector(['modelV2']) as (small_model_logs, _): time.sleep(3) + with Timeout(10, "chestnut did not enumerate"): + while not chestnut_present(): + time.sleep(0.5) + msgs = {s: [m for m in logs if m.which() == s] for s in services} for service, messages in msgs.items(): with subtests.test(service=service): From 64be6c80d39c27608d1d74abf8d725938bc41912 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Sat, 10 Oct 2026 09:47:34 -0700 Subject: [PATCH 219/270] fix downgrade to last release (#39063) * fix downgrade to releases before the openpilot/ move * trigger ci --- launch_chffrplus.sh | 2 ++ openpilot/system/updated/updated.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/launch_chffrplus.sh b/launch_chffrplus.sh index 240bc3b648..806e41d4c1 100755 --- a/launch_chffrplus.sh +++ b/launch_chffrplus.sh @@ -56,6 +56,8 @@ function launch { mv "$DIR" /data/safe_staging/old_openpilot mv "${STAGING_ROOT}/finalized" "$DIR" cd "$DIR" + # releases before the openpilot/ move can't swap AGNOS on newer AGNOS, use ours + [ ! -L system/hardware/tici/agnos.json ] && /data/safe_staging/old_openpilot/openpilot/common/hardware/comma/agnos.py --verify system/hardware/tici/agnos.json && sudo reboot echo "Restarting launch script ${LAUNCHER_LOCATION}" unset AGNOS_VERSION diff --git a/openpilot/system/updated/updated.py b/openpilot/system/updated/updated.py index 1bba66d0c9..c1ff75fa4b 100755 --- a/openpilot/system/updated/updated.py +++ b/openpilot/system/updated/updated.py @@ -219,6 +219,8 @@ def handle_agnos_update() -> None: cloudlog.info(f"Beginning background installation for AGNOS {updated_version}") manifest_path = os.path.join(OVERLAY_MERGED, "openpilot/system/hardware/comma/agnos.json") + if not os.path.isfile(manifest_path): # releases before the openpilot/ move + manifest_path = os.path.join(OVERLAY_MERGED, "system/hardware/tici/agnos.json") target_slot_number = get_target_slot_number() flash_agnos_update(manifest_path, target_slot_number, cloudlog) From 6f4c0faf1f85132734e94aa39c67143f823b9324 Mon Sep 17 00:00:00 2001 From: Warren Date: Sat, 10 Oct 2026 14:07:36 -0400 Subject: [PATCH 220/270] Mountain Dew Model (#39047) * big model: desires-final (ea1600cb/12864) * big model: mountain dew (870a4823/1284) * big model: MD retrained (8d736683/12864) * rebase and recompile --------- Co-authored-by: Bruce Wayne Co-authored-by: YassineYousfi --- openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl index 48a94630eb..0d35bf3f86 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:51420a138242fe57dc4c2d2530ae8edb74afbd46f3282be8995ee3b603b79c04 -size 799942038 +oid sha256:256f2178366479a211933701c03518621ed3970289e6a8a43e5a2bcb15e55b5f +size 799942179 From 4c362ca3272c7418a068d43c402c84bd0544f35a Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Sun, 11 Oct 2026 17:49:50 -0700 Subject: [PATCH 221/270] nav alpha (#39066) * nav is back * start cleanup * prefetch all tiles * one map * debug * lil more * lil more --- openpilot/cereal/log.capnp | 4 +- openpilot/cereal/services.py | 2 + openpilot/common/constants.py | 4 + openpilot/common/params_keys.h | 2 + openpilot/selfdrive/navigation/__init__.py | 0 openpilot/selfdrive/navigation/helpers.py | 9 + openpilot/selfdrive/navigation/mapsd.py | 84 +++ openpilot/selfdrive/navigation/navd.py | 225 ++++++++ openpilot/selfdrive/navigation/pmtiles.py | 136 +++++ openpilot/selfdrive/navigation/renderer.py | 208 ++++++++ openpilot/selfdrive/navigation/route.py | 71 +++ .../navigation/tests/test_navigation.py | 485 ++++++++++++++++++ openpilot/selfdrive/navigation/tiles.py | 117 +++++ openpilot/selfdrive/navigation/vector_tile.py | 133 +++++ .../ui/mici/layouts/settings/developer.py | 6 +- .../ui/mici/onroad/augmented_road_view.py | 18 +- .../selfdrive/ui/mici/onroad/navigation.py | 124 +++++ .../selfdrive/ui/mici/onroad/torque_bar.py | 4 +- openpilot/selfdrive/ui/ui_state.py | 6 + openpilot/system/athena/athenad.py | 14 + openpilot/system/athena/tests/test_athenad.py | 15 + openpilot/system/manager/process_config.py | 5 + 22 files changed, 1663 insertions(+), 9 deletions(-) create mode 100644 openpilot/selfdrive/navigation/__init__.py create mode 100644 openpilot/selfdrive/navigation/helpers.py create mode 100755 openpilot/selfdrive/navigation/mapsd.py create mode 100755 openpilot/selfdrive/navigation/navd.py create mode 100644 openpilot/selfdrive/navigation/pmtiles.py create mode 100644 openpilot/selfdrive/navigation/renderer.py create mode 100644 openpilot/selfdrive/navigation/route.py create mode 100644 openpilot/selfdrive/navigation/tests/test_navigation.py create mode 100644 openpilot/selfdrive/navigation/tiles.py create mode 100644 openpilot/selfdrive/navigation/vector_tile.py create mode 100644 openpilot/selfdrive/ui/mici/onroad/navigation.py diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 82d4b1ba73..4980b296fe 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -2710,8 +2710,8 @@ struct Event { driverMonitoringStateDEPRECATED @71 :DriverMonitoringStateDEPRECATED; gpsNMEADEPRECATED @3 :GPSNMEAData; uploaderStateDEPRECATED @79 :UploaderState; - navInstructionDEPRECATED @82 :NavInstruction; - navRouteDEPRECATED @83 :NavRoute; + navInstruction @82 :NavInstruction; + navRoute @83 :NavRoute; navThumbnailDEPRECATED @84 :Thumbnail; gnssMeasurementsDEPRECATED @91 :GnssMeasurements; mapRenderStateDEPRECATED @105: MapRenderState; diff --git a/openpilot/cereal/services.py b/openpilot/cereal/services.py index e8be582072..1cb007d24a 100755 --- a/openpilot/cereal/services.py +++ b/openpilot/cereal/services.py @@ -52,6 +52,8 @@ _services: dict[str, tuple] = { "procLog": (True, 0.5, 15, QueueSize.BIG), "gpsLocationExternal": (True, 10., 10), "gpsLocation": (True, 1., 1), + "navInstruction": (True, 1., 10), + "navRoute": (True, 0.), "ubloxGnss": (True, 10.), "qcomGnss": (True, 2.), "clocks": (True, 0.1, 1), diff --git a/openpilot/common/constants.py b/openpilot/common/constants.py index 7ca425c4b2..a20af50295 100644 --- a/openpilot/common/constants.py +++ b/openpilot/common/constants.py @@ -16,6 +16,10 @@ class CV: DEG_TO_RAD = np.pi / 180. RAD_TO_DEG = 1. / DEG_TO_RAD + # Length + METER_TO_FOOT = 3.28084 + METER_TO_MILE = 1. / 1609.344 + # Mass LB_TO_KG = 0.453592 diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 93374fdc02..15fc62dfca 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -87,6 +87,8 @@ inline static std::unordered_map keys = { {"LateralManeuverMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"LongitudinalManeuverMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"LongitudinalPersonality", {PERSISTENT, INT, std::to_string(static_cast(cereal::LongitudinalPersonality::STANDARD))}}, + {"NavigationEnabled", {PERSISTENT, BOOL, "0"}}, + {"NavDestination", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, JSON}}, {"NetworkMetered", {PERSISTENT, BOOL}}, {"ObdMultiplexingChanged", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"ObdMultiplexingEnabled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, diff --git a/openpilot/selfdrive/navigation/__init__.py b/openpilot/selfdrive/navigation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpilot/selfdrive/navigation/helpers.py b/openpilot/selfdrive/navigation/helpers.py new file mode 100644 index 0000000000..a880c79b06 --- /dev/null +++ b/openpilot/selfdrive/navigation/helpers.py @@ -0,0 +1,9 @@ +import time + + +def latest_gps_source(sm) -> str | None: + """The service with the freshest usable GPS fix, or None.""" + now = time.monotonic() + sources = [s for s in ('gpsLocation', 'gpsLocationExternal') + if sm.valid[s] and sm[s].hasFix and 0 <= now - sm.recv_time[s] < 2] + return max(sources, key=lambda s: sm.logMonoTime[s]) if sources else None diff --git a/openpilot/selfdrive/navigation/mapsd.py b/openpilot/selfdrive/navigation/mapsd.py new file mode 100755 index 0000000000..43bd7c4f75 --- /dev/null +++ b/openpilot/selfdrive/navigation/mapsd.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +import time + +import numpy as np + +import openpilot.cereal.messaging as messaging +from msgq.visionipc import VisionIpcServer +from openpilot.cereal.visionipc import VisionStreamType +from openpilot.common.realtime import Ratekeeper +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.navigation.helpers import latest_gps_source +from openpilot.selfdrive.navigation.pmtiles import PMTiles +from openpilot.selfdrive.navigation.renderer import SIZE, MapRenderer +from openpilot.selfdrive.navigation.route import RouteTracker +from openpilot.selfdrive.navigation.tiles import RemoteTiles + +RETRY_SECONDS = 10 + + +class MapsD: + def __init__(self): + self.renderer = None + self.tracker = None + self.route = np.empty((0, 2)) + self.prefetched = False + self.retry_at = 0.0 + + def update(self, sm) -> tuple[np.ndarray, int]: + """The map frame for the latest messages: black with a zero timestamp without a usable map, never stale.""" + if sm.updated['navRoute']: + route = np.array([(c.longitude, c.latitude) for c in sm['navRoute'].coordinates], dtype=np.float64).reshape((-1, 2)) + if not np.array_equal(route, self.route): + self.route = route + self.tracker = RouteTracker(route) if len(route) >= 2 else None # progress along the route starts over + self.prefetched = False + now = time.monotonic() + source = latest_gps_source(sm) + try: + if self.tracker is not None and now >= self.retry_at: + if self.renderer is None: + self.renderer = MapRenderer(PMTiles(RemoteTiles())) + if not self.prefetched: + # TODO: fetch the tiles more intelligently on demand + # Every tile the route can show is fetched up front, so each render below is a cache hit. + self.renderer.prefetch(self.route) + self.prefetched = True + if source is not None: + gps = sm[source] + # As in training, the map is centered on the route and headed along it, not at the GPS fix. + image, valid = self.renderer.render(*self.tracker.snap(gps.longitude, gps.latitude), self.route) + if valid: + return image, sm.logMonoTime[source] + except Exception: # a failed fetch, or a corrupt cached tile + cloudlog.exception('mapsd.render_failed') + self.close() # a changed archive needs a fresh reader + self.retry_at = now + RETRY_SECONDS + return np.zeros((SIZE, SIZE), dtype=np.uint8), 0 + + def close(self): + if self.renderer is not None: + self.renderer.archive.close() + self.renderer = None + self.prefetched = False + + +def main(): + rk = Ratekeeper(20.0) + sm = messaging.SubMaster(['gpsLocation', 'gpsLocationExternal', 'navRoute']) + server = VisionIpcServer('mapsd') + server.create_buffers_with_sizes(VisionStreamType.VISION_STREAM_MAP, 4, SIZE, SIZE, SIZE * SIZE, SIZE, SIZE * SIZE) + server.start_listener() + mapsd = MapsD() + try: + while True: + sm.update(0) + image, timestamp = mapsd.update(sm) + server.send(VisionStreamType.VISION_STREAM_MAP, image.tobytes(), rk.frame, timestamp, time.monotonic_ns()) + rk.keep_time() + finally: + mapsd.close() + + +if __name__ == '__main__': + main() diff --git a/openpilot/selfdrive/navigation/navd.py b/openpilot/selfdrive/navigation/navd.py new file mode 100755 index 0000000000..a33c42558c --- /dev/null +++ b/openpilot/selfdrive/navigation/navd.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +import os + +import numpy as np +import requests + +import openpilot.cereal.messaging as messaging +from openpilot.common.api import API_HOST, Api +from openpilot.common.params import Params +from openpilot.common.realtime import Ratekeeper +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.navigation.helpers import latest_gps_source +from openpilot.selfdrive.navigation.route import along_path, local_meters + +REROUTE_DISTANCE = 25 +MANEUVER_TRANSITION_THRESHOLD = 10 +REROUTE_COUNTER_MIN = 3 +ROUTE_RESEND_PERIOD = 10 # updates (seconds); a restarted mapsd gets the route within this +RECOMPUTE_HOLD = 10 # updates (seconds) between route requests + + +def set_banner(instruction, banners, distance_to_maneuver): + """Fill the banner fields from the banner to show now: the last one still ahead of the ego, else the first.""" + if not banners: + return + banner = next((b for b in reversed(banners) if distance_to_maneuver < b['distanceAlongGeometry']), banners[0]) + instruction.showFull = distance_to_maneuver < banner['distanceAlongGeometry'] + primary = banner['primary'] + instruction.maneuverPrimaryText = primary.get('text') or '' + instruction.maneuverType = primary.get('type') or '' + instruction.maneuverModifier = primary.get('modifier') or '' + if banner.get('secondary') is not None: + instruction.maneuverSecondaryText = banner['secondary']['text'] + + +class RouteEngine: + def __init__(self, sm, pm, params=None): + self.sm = sm + self.pm = pm + self.params = params if params is not None else Params() + + self.last_position = None # (longitude, latitude) + self.last_bearing = None + + self.nav_destination = None + self.step_idx = None + self.route = None + self.route_geometry = None # an (n, 2) array of (longitude, latitude) per step + + self.recompute_hold = 0 + self.reroute_counter = 0 + + self.mapbox_token = os.environ.get("MAPBOX_TOKEN") + self.api = Api(self.params.get("DongleId")) if self.mapbox_token is None else None + self.mapbox_host = "https://api.mapbox.com" if self.mapbox_token is not None else API_HOST.rstrip('/') + '/v1/nav' + + def update(self): + self.sm.update(0) + + source = latest_gps_source(self.sm) + if source is not None: + gps = self.sm[source] + self.last_position, self.last_bearing = (gps.longitude, gps.latitude), gps.bearingDeg + route = self.route + try: + self.recompute_route(gps_ok=source is not None) + self.send_instruction() + except Exception: + cloudlog.exception("navd.failed_to_compute") + # mapsd dedupes repeats, so a periodic resend is how a restarted mapsd gets the route + if self.route is not route or self.sm.frame % ROUTE_RESEND_PERIOD == 0: + self.send_route() + + def recompute_route(self, gps_ok: bool): + if self.last_position is None: + return + + destination = self.params.get("NavDestination") + if destination is None: + self.clear_route() + self.recompute_hold = 0 + return + new_destination = (destination['longitude'], destination['latitude']) + + # Don't recompute when GPS drifts in tunnels + if not gps_ok and self.step_idx is not None: + return + + self.recompute_hold -= 1 + if (self.should_recompute() or new_destination != self.nav_destination) and self.recompute_hold <= 0: + self.recompute_hold = RECOMPUTE_HOLD + self.calculate_route(new_destination) + self.reroute_counter = 0 + + def calculate_route(self, destination): + assert self.last_position is not None + cloudlog.warning(f"Calculating route {self.last_position} -> {destination}") + self.nav_destination = destination + + lang = self.params.get('LanguageSetting') + if lang is not None: + lang = lang.replace('main_', '') + + token = self.mapbox_token + if token is None: + token = self.api.get_token() + + params = { + 'access_token': token, + 'geometries': 'geojson', + 'overview': 'full', + 'steps': 'true', + 'banner_instructions': 'true', + 'alternatives': 'false', + 'language': lang, + } + if self.last_bearing is not None: + params['bearings'] = f"{(self.last_bearing + 360) % 360:.0f},90;" + + coords_str = ';'.join(f'{lon},{lat}' for lon, lat in (self.last_position, destination)) + url = self.mapbox_host + '/directions/v5/mapbox/driving-traffic/' + coords_str + try: + resp = requests.get(url, params=params, timeout=10) + if resp.status_code != 200: + cloudlog.event("API request failed", status_code=resp.status_code, text=resp.text, error=True) + resp.raise_for_status() + + r = resp.json() + if len(r['routes']): + self.route = r['routes'][0]['legs'][0]['steps'] + self.route_geometry = [np.array(step['geometry']['coordinates'], dtype=np.float64).reshape((-1, 2)) for step in self.route] + self.step_idx = 0 + else: + cloudlog.warning("Got empty route response") + self.clear_route() + + except requests.exceptions.RequestException: + cloudlog.exception("failed to get route") + self.clear_route() + + def send_instruction(self): + msg = messaging.new_message('navInstruction', valid=True) + + if self.step_idx is None: + msg.valid = False + self.pm.send('navInstruction', msg) + return + + assert self.route is not None and self.route_geometry is not None and self.last_position is not None + step = self.route[self.step_idx] + along_geometry, _ = along_path(self.route_geometry[self.step_idx], self.last_position) + distance_to_maneuver_along_geometry = step['distance'] - along_geometry + + # Banner instructions are for the following maneuver step, don't use empty last step + banner_step = step + if not len(banner_step['bannerInstructions']) and self.step_idx == len(self.route) - 1: + banner_step = self.route[max(self.step_idx - 1, 0)] + + # Current instruction + msg.navInstruction.maneuverDistance = distance_to_maneuver_along_geometry + set_banner(msg.navInstruction, banner_step['bannerInstructions'], distance_to_maneuver_along_geometry) + + # Total remaining distance and time: the rest of this step, then every step after it + remaining = 1.0 - along_geometry / max(step['distance'], 1) + future = self.route[self.step_idx + 1:] + msg.navInstruction.distanceRemaining = step['distance'] * remaining + sum(s['distance'] for s in future) + msg.navInstruction.timeRemaining = step['duration'] * remaining + sum(s['duration'] for s in future) + + self.pm.send('navInstruction', msg) + + # Transition to next route segment + if distance_to_maneuver_along_geometry < -MANEUVER_TRANSITION_THRESHOLD: + if self.step_idx + 1 < len(self.route): + self.step_idx += 1 + self.recompute_hold = 0 + else: + cloudlog.warning("Destination reached") + + # Clear route if driving away from destination + if np.linalg.norm(local_meters(self.last_position, self.nav_destination)) > REROUTE_DISTANCE: + self.params.remove("NavDestination") + self.clear_route() + + def send_route(self): + coords = np.concatenate(self.route_geometry) if self.route_geometry is not None else np.empty((0, 2)) + msg = messaging.new_message('navRoute', valid=True) + msg.navRoute.coordinates = [{'longitude': lon, 'latitude': lat} for lon, lat in coords.tolist()] + self.pm.send('navRoute', msg) + + def clear_route(self): + self.route = None + self.route_geometry = None + self.step_idx = None + self.nav_destination = None + + def should_recompute(self): + if self.step_idx is None: + return True + + assert self.route is not None and self.route_geometry is not None and self.last_position is not None + # Don't recompute in last segment, assume destination is reached + if self.step_idx == len(self.route) - 1: + return False + + # TODO: Check for going wrong way in segment + if along_path(self.route_geometry[self.step_idx], self.last_position)[1] > REROUTE_DISTANCE: + self.reroute_counter += 1 + else: + self.reroute_counter = 0 + return self.reroute_counter > REROUTE_COUNTER_MIN + + +def main(): + pm = messaging.PubMaster(['navInstruction', 'navRoute']) + sm = messaging.SubMaster(['gpsLocation', 'gpsLocationExternal']) + + rk = Ratekeeper(1.0) + route_engine = RouteEngine(sm, pm) + while True: + route_engine.update() + rk.keep_time() + + +if __name__ == "__main__": + main() diff --git a/openpilot/selfdrive/navigation/pmtiles.py b/openpilot/selfdrive/navigation/pmtiles.py new file mode 100644 index 0000000000..1279963228 --- /dev/null +++ b/openpilot/selfdrive/navigation/pmtiles.py @@ -0,0 +1,136 @@ +"""PMTiles v3 reader for the gzip-compressed Protomaps MVT basemap, over any byte-range source.""" +import gzip +import struct +from bisect import bisect_right +from functools import lru_cache + +HEADER_LENGTH = 127 + + +def read_varint(data: bytes, offset: int) -> tuple[int, int]: + value = 0 + for shift in range(0, 70, 7): + if offset >= len(data): + raise ValueError("Truncated varint") + byte = data[offset] + offset += 1 + value |= (byte & 127) << shift + if byte < 128: + return value, offset + raise ValueError("Varint exceeds 64 bits") + + +def tile_id(z: int, x: int, y: int) -> int: + """PMTiles orders each zoom level along a Hilbert curve.""" + if not 0 <= z <= 26 or not 0 <= x < 1 << z or not 0 <= y < 1 << z: + raise ValueError("Invalid tile coordinates") + distance = 0 + size = (1 << z) // 2 + while size: + rx, ry = int(bool(x & size)), int(bool(y & size)) + distance += size * size * ((3 * rx) ^ ry) + if not ry: + if rx: + x, y = size - 1 - x, size - 1 - y + x, y = y, x + size //= 2 + return ((1 << (2 * z)) - 1) // 3 + distance + + +def decode_directory(data: bytes) -> tuple[list[int], list[tuple[int, int, int]]]: + count, offset = read_varint(data, 0) + if count > len(data): + raise ValueError("Invalid directory entry count") + columns = [] + for _ in range(4): + column = [] + for _ in range(count): + value, offset = read_varint(data, offset) + column.append(value) + columns.append(column) + ids, runs, lengths, offsets = columns + for i in range(count): + if i: + ids[i] += ids[i - 1] + if offsets[i] == 0: + if i == 0: + raise ValueError("First directory offset cannot be implicit") + offsets[i] = offsets[i - 1] + lengths[i - 1] + else: + offsets[i] -= 1 + if lengths[i] == 0 or (i and ids[i] <= ids[i - 1]): + raise ValueError("Invalid directory entry") + if offset != len(data): + raise ValueError("Trailing directory data") + return ids, list(zip(runs, lengths, offsets, strict=True)) + + +class PMTiles: + def __init__(self, source): + """source has size, read(offset, length), prefetch(ranges) and close().""" + self.source = source + self.size = source.size + try: + header = self.read(0, HEADER_LENGTH) + if header[:8] != b'PMTiles\x03': + raise ValueError("Expected a PMTiles v3 archive") + self.root_offset, self.root_length, _, _, self.leaf_offset, self.leaf_length, self.data_offset, self.data_length = struct.unpack_from('<8Q', header, 8) + compression, tile_type, self.min_zoom, self.max_zoom = header[97:99], header[99], header[100], header[101] + if compression != b'\x02\x02' or tile_type != 1: + raise ValueError("Expected gzip-compressed MVT vector tiles") + if not 0 <= self.min_zoom <= self.max_zoom <= 26: + raise ValueError("Unsupported PMTiles zoom range") + for offset, length in ((self.root_offset, self.root_length), (self.leaf_offset, self.leaf_length), (self.data_offset, self.data_length)): + if offset + length > self.size: + raise ValueError("PMTiles section outside archive") + self.directory = lru_cache(maxsize=32)(self._read_directory) + self.directory(self.root_offset, self.root_length) + except Exception: + self.source.close() + raise + + def read(self, offset: int, length: int) -> bytes: + if offset < 0 or length < 0 or offset + length > self.size: + raise ValueError("PMTiles read outside archive") + data = self.source.read(offset, length) + if len(data) != length: + raise ValueError("Truncated PMTiles archive") + return data + + def _read_directory(self, offset: int, length: int): + return decode_directory(gzip.decompress(self.read(offset, length))) + + def locate(self, z: int, x: int, y: int) -> tuple[int, int] | None: + """The byte range of a tile in the archive, or None when there is no such tile.""" + if not self.min_zoom <= z <= self.max_zoom: + return None + target = tile_id(z, x, y) + offset, length = self.root_offset, self.root_length + for _ in range(4): + ids, entries = self.directory(offset, length) + index = bisect_right(ids, target) - 1 + if index < 0: + return None + run, length, offset = entries[index] + if run: + if target >= ids[index] + run: + return None + if offset + length > self.data_length: + raise ValueError("Tile outside PMTiles data section") + return self.data_offset + offset, length + if offset + length > self.leaf_length: + raise ValueError("Directory outside PMTiles leaf section") + offset += self.leaf_offset + raise ValueError("PMTiles directory nesting exceeds four levels") + + def get(self, z: int, x: int, y: int) -> bytes | None: + located = self.locate(z, x, y) + return gzip.decompress(self.read(*located)) if located else None + + def prefetch(self, tiles): + """Cache the (z, x, y) tiles ahead of reads.""" + self.source.prefetch([located for located in (self.locate(*tile) for tile in tiles) if located]) + + def close(self): + self.directory.cache_clear() + self.source.close() diff --git a/openpilot/selfdrive/navigation/renderer.py b/openpilot/selfdrive/navigation/renderer.py new file mode 100644 index 0000000000..a0640a7cad --- /dev/null +++ b/openpilot/selfdrive/navigation/renderer.py @@ -0,0 +1,208 @@ +"""Deterministic CPU rasterization of Protomaps roads and a supplied route. + +Styled after the training map renderer: 1 m per pixel, the ego three quarters +down the frame, pure white roads whose casings are part of the stroke, bridges cutting over the roads beneath +them, a grey route and a white ego arrow. +""" +import math +from functools import lru_cache + +import numpy as np + +from openpilot.selfdrive.navigation.pmtiles import PMTiles +from openpilot.selfdrive.navigation.route import cumulative_distance, nearest_segment +from openpilot.selfdrive.navigation.vector_tile import Road, decode_roads + +SIZE = 256 +ZOOM = 15 # tile zoom; the style zoom is derived from the 1 m per pixel scale instead +FORWARD_OFFSET = 64.0 # the ego sits three quarters down the frame +EGO = np.array([SIZE / 2, SIZE / 2 + FORWARD_OFFSET]) # pixel position of the ego +EARTH_CIRCUMFERENCE = 40075016.68557849 # equatorial, for Web Mercator +BACKGROUND = 0 +ROAD_INTENSITY = 255 +ROUTE_INTENSITY = 128 +ROUTE_WIDTH = 5.0 # pixels +CUT_WIDTH = 3.0 # black separator under a bridge, added to the road width +MAX_ROAD_DISTANCE = 100.0 # meters from the ego to the nearest road centerline for a valid map +ARROW = np.array([(0, -9), (7, 7), (0, 3), (-7, 7)], dtype=np.float64) # ego marker, pixels around the ego +ARROW_OUTLINE = 2.0 # pixels + + +# The training style's road classes as (zoom, width, zoom, width) stops for the interior and for one side of the +# casing, in pixels. Casings are white there too, so they only widen the stroke. Only each class's top interval is +# kept: the style zoom at 1 m per pixel stays above 14 until 78 degrees of latitude, where the lower stops begin. +MAJOR, MEDIUM, STREET, MINOR, TRACK = range(5) +STOPS = np.array([[[5, 0.75, 18, 32], [10, 1, 18, 2]], # MAJOR: motorway, trunk, primary + [[5, 0.1, 18, 26], [10, 0.75, 18, 2]], # MEDIUM: secondary, tertiary + [[14, 2, 18, 18], [12, 0.75, 20, 2]], # STREET: residential, unclassified, living streets, major links + [[14, 0.5, 18, 12], [12, 0.75, 20, 2]], # MINOR: service roads and minor links + [[14, 1, 18, 12], [12, 0.75, 20, 2]]], dtype=np.float64) # TRACK + + +def stroke_widths(zoom: float, styles: np.ndarray, levels: np.ndarray) -> np.ndarray: + """Full stroke width of each segment: the style's exponential (base 1.5) interpolation between its stops, flat + beyond them, with the casing on both sides except for tunnels, which are drawn at their interior width.""" + z0, w0, z1, w1 = np.moveaxis(STOPS, -1, 0) + z = np.clip(zoom, z0, z1) + table = w0 + (w1 - w0) * (1.5 ** (z - z0) - 1) / (1.5 ** (z1 - z0) - 1) # (style, interior / casing) + return table[styles, 0] + np.where(levels < 0, 0, 2 * table[styles, 1]) + + +def road_style(road: Road) -> int | None: + """The style for a road, or None when hidden. Protomaps kind_detail is the underlying OSM highway type.""" + detail = road.detail + if detail in ('platform', 'pier', 'raceway'): + return None + if detail in ('motorway', 'trunk', 'primary') or (not detail and road.kind == 'highway'): + return MAJOR + if detail in ('secondary', 'tertiary') or (not detail and road.kind == 'major_road'): + return MEDIUM + if detail == 'track': + return TRACK + if detail in ('secondary_link', 'tertiary_link', 'service', 'driveway', 'parking_aisle', 'alley') or ( + road.kind == 'other' and detail != 'living_street'): + return MINOR + return STREET + + +def road_level(road: Road) -> int: + """Draw order: tunnels, then ground roads, then bridges, each cutting over the levels beneath.""" + return 1 if road.bridge else -1 if road.tunnel else 0 + + +def mercator(coordinates: np.ndarray) -> np.ndarray: + """Longitude/latitude -> normalized Web Mercator, x east / y south.""" + longitude, latitude = coordinates.T + y = (1.0 - np.arcsinh(np.tan(np.deg2rad(latitude))) / np.pi) / 2.0 + return np.column_stack(((longitude + 180.0) / 360.0, y)) + + +def project(points: np.ndarray, longitude: float, latitude: float, bearing: float) -> np.ndarray: + """Mercator points to pixels of a frame at a position, headed along a bearing in degrees clockwise from north.""" + origin = mercator(np.array([[longitude, latitude]]))[0] + delta = points - origin + delta[:, 0] = (delta[:, 0] + 0.5) % 1.0 - 0.5 # wrap across the antimeridian + delta *= EARTH_CIRCUMFERENCE * math.cos(math.radians(latitude)) # meters, which are pixels + c, s = math.cos(math.radians(bearing)), math.sin(math.radians(bearing)) + return EGO + delta @ np.array([[c, -s], [s, c]]) # x right, y down + + +def draw_segments(image: np.ndarray, starts: np.ndarray, ends: np.ndarray, radii: np.ndarray | float, intensity: int, + *, overlay: bool = False, caps: np.ndarray | None = None): + """Strokes with round joins and one pixel of analytic edge antialiasing. + + caps is (segments, 2): whether each segment's start/end gets a round cap; without it every end is round. + Max blending (the default) makes draw order irrelevant; overlay replaces the pixels beneath. + """ + radii = np.broadcast_to(np.asarray(radii, dtype=np.float64), (len(starts),)) + reach = radii[:, None] + 1 + low = np.maximum(np.floor(np.minimum(starts, ends) - reach), 0).astype(int) + high = np.minimum(np.ceil(np.maximum(starts, ends) + reach), [image.shape[1] - 1, image.shape[0] - 1]).astype(int) + # Cull offscreen segments in one pass, then loop over plain Python floats. + visible = np.flatnonzero((high >= low).all(axis=1)) + round_caps = np.ones((len(starts), 2), dtype=bool) if caps is None else caps + for i, (sx, sy), (ex, ey), (x0, y0), (x1, y1), radius in zip(visible.tolist(), starts[visible].tolist(), ends[visible].tolist(), + low[visible].tolist(), high[visible].tolist(), radii[visible].tolist(), + strict=True): + dx, dy = ex - sx, ey - sy + px = np.arange(x0, x1 + 1) + (0.5 - sx) + py = (np.arange(y0, y1 + 1) + (0.5 - sy))[:, None] + along = (px * dx + py * dy) / max(dx * dx + dy * dy, 1e-12) + t = np.clip(along, 0, 1) + alpha = np.clip(radius + 0.5 - np.hypot(px - t * dx, py - t * dy), 0, 1) + if not round_caps[i, 0]: + alpha = alpha * (along >= 0) + if not round_caps[i, 1]: + alpha = alpha * (along <= 1) + view = image[y0:y1 + 1, x0:x1 + 1] + if overlay: + view[:] = np.rint(view * (1 - alpha) + intensity * alpha).astype(np.uint8) + else: + np.maximum(view, np.rint(alpha * intensity).astype(np.uint8), out=view) + + +def draw_vehicle(image: np.ndarray): + """The training ego marker: a white arrow with a black outline, drawn over everything else.""" + arrow = ARROW + EGO + (x0, y0), (x1, y1) = (np.floor(arrow.min(axis=0)) - 2).astype(int), (np.ceil(arrow.max(axis=0)) + 2).astype(int) + px, py = np.meshgrid(np.arange(x0, x1) + 0.5, np.arange(y0, y1) + 0.5) + inside = np.zeros(px.shape, dtype=bool) + for a, b, c in ((arrow[0], arrow[1], arrow[2]), (arrow[0], arrow[2], arrow[3])): # the concave arrow as two triangles + sides = [(q[0] - p[0]) * (py - p[1]) - (q[1] - p[1]) * (px - p[0]) for p, q in ((a, b), (b, c), (c, a))] + inside |= ((sides[0] >= 0) & (sides[1] >= 0) & (sides[2] >= 0)) | ((sides[0] <= 0) & (sides[1] <= 0) & (sides[2] <= 0)) + image[y0:y1, x0:x1][inside] = 255 + draw_segments(image, arrow, np.roll(arrow, -1, axis=0), ARROW_OUTLINE / 2, 0, overlay=True) + + +class MapRenderer: + def __init__(self, archive: PMTiles): + self.archive = archive + self.zoom = min(max(ZOOM, archive.min_zoom), archive.max_zoom) + self.roads = lru_cache(maxsize=128)(self._load_roads) + + def _load_roads(self, x: int, y: int): + """A tile's visible road segments as flat arrays: mercator starts/ends, style index, level and polyline end flags.""" + tile = self.archive.get(self.zoom, x, y) + if tile is None: + return None + starts, ends, styles, levels, caps = [np.empty((0, 2))], [np.empty((0, 2))], [np.empty(0, int)], [np.empty(0, int)], [np.empty((0, 2), bool)] + for road in decode_roads(tile): + style = road_style(road) + if style is None: + continue + points = (road.points + np.array([x, y])) / (1 << self.zoom) + n = len(points) - 1 + starts.append(points[:-1]) + ends.append(points[1:]) + styles.append(np.full(n, style)) + levels.append(np.full(n, road_level(road))) + ends_of_line = np.zeros((n, 2), dtype=bool) + ends_of_line[0, 0] = ends_of_line[-1, 1] = True + caps.append(ends_of_line) + return np.concatenate(starts), np.concatenate(ends), np.concatenate(styles), np.concatenate(levels), np.concatenate(caps) + + def view_tiles(self, longitude: float, latitude: float) -> list[tuple[int, int]]: + """Tiles covering the view at a point for every heading.""" + origin = mercator(np.array([[longitude, latitude]]))[0] + # A bounding circle includes all headings, forward offset and road stroke widths. + radius = (SIZE / math.sqrt(2) + FORWARD_OFFSET + 16) / (EARTH_CIRCUMFERENCE * math.cos(math.radians(latitude))) + count = 1 << self.zoom + low = np.floor((origin - radius) * count).astype(int) + high = np.floor((origin + radius) * count).astype(int) + return [(x % count, y) for y in range(low[1], high[1] + 1) for x in range(low[0], high[0] + 1) if 0 <= y < count] + + def route_tiles(self, route: np.ndarray) -> set[tuple[int, int]]: + """Every tile the view can show while the pose is snapped onto the route, sampled every 100 m.""" + distance = cumulative_distance(route) + samples = np.column_stack([np.interp(np.arange(0, distance[-1], 100), distance, route[:, axis]) for axis in (0, 1)]) + return {tile for lon, lat in np.vstack((samples, route)).tolist() for tile in self.view_tiles(lon, lat)} + + def prefetch(self, route: np.ndarray): + self.archive.prefetch((self.zoom, x, y) for x, y in self.route_tiles(route)) + + def render(self, longitude: float, latitude: float, bearing: float, route: np.ndarray) -> tuple[np.ndarray, bool]: + """The map image at a position and bearing, and whether every tile was present with a road near the ego.""" + image = np.full((SIZE, SIZE), BACKGROUND, dtype=np.uint8) + loaded = [self.roads(x, y) for x, y in self.view_tiles(longitude, latitude)] + tiles = [tile for tile in loaded if tile is not None] + # The training renderer's latitude-dependent style zoom: the web mercator zoom with one meter per pixel + # at this latitude, under the 512-pixel tile convention. Tile zoom and style zoom are independent. + style_zoom = math.log2(math.cos(math.radians(latitude)) * EARTH_CIRCUMFERENCE / 256) - 1 + road_distance = math.inf + if tiles: + starts, ends, styles, levels, caps = (np.concatenate(column) for column in zip(*tiles, strict=True)) + starts, ends = project(starts, longitude, latitude, bearing), project(ends, longitude, latitude, bearing) + widths = stroke_widths(style_zoom, styles, levels) + if len(starts): + road_distance = nearest_segment(starts, ends, EGO)[2] + for level in np.unique(levels): + chosen = levels == level + if level > levels.min(): + # Cut the roads beneath, open at way endpoints so a bridge does not end in a black oval. + draw_segments(image, starts[chosen], ends[chosen], (widths[chosen] + CUT_WIDTH) / 2, BACKGROUND, + overlay=True, caps=~caps[chosen]) + draw_segments(image, starts[chosen], ends[chosen], widths[chosen] / 2, ROAD_INTENSITY) + pixels = project(mercator(route), longitude, latitude, bearing) + draw_segments(image, pixels[:-1], pixels[1:], ROUTE_WIDTH / 2, ROUTE_INTENSITY, overlay=True) + draw_vehicle(image) + return image, len(tiles) == len(loaded) and road_distance <= MAX_ROAD_DISTANCE diff --git a/openpilot/selfdrive/navigation/route.py b/openpilot/selfdrive/navigation/route.py new file mode 100644 index 0000000000..d1fc85aca7 --- /dev/null +++ b/openpilot/selfdrive/navigation/route.py @@ -0,0 +1,71 @@ +"""Planar geometry for routes, and route-relative map pose and progress tracking.""" +import numpy as np + +METERS_PER_DEGREE = 2 * np.pi * 6371008.8 / 360 # mean Earth radius, as Mapbox measures distances + +# Training renders the map on the route rather than at the measured position: +# the ego is projected onto the route, the frame is centered there and headed along the route's tangent. +PROJECTION_WINDOW = (-30.0, 100.0) # meters around the previous progress to search; a loop cannot capture the ego +HEADING_HALF_WINDOW = 50.0 # meters; the heading is the chord of the route this far behind and ahead + + +def local_meters(points, origin) -> np.ndarray: + """Equirectangular east/north meters of (longitude, latitude) points around an origin.""" + origin = np.asarray(origin, dtype=np.float64) + scale = np.array([METERS_PER_DEGREE * np.cos(np.radians(origin[1])), METERS_PER_DEGREE]) + return (np.asarray(points, dtype=np.float64) - origin) * scale + + +def nearest_segment(starts: np.ndarray, ends: np.ndarray, point) -> tuple[int, float, float]: + """(index, fraction along it, distance) of the closest spot on the segments, in the inputs' units.""" + ab = ends - starts + t = np.clip(((point - starts) * ab).sum(axis=1) / np.maximum((ab * ab).sum(axis=1), 1e-12), 0, 1) + d = np.linalg.norm(starts + t[:, None] * ab - point, axis=1) + i = int(d.argmin()) + return i, float(t[i]), float(d[i]) + + +def cumulative_distance(route: np.ndarray) -> np.ndarray: + """Meters along [(longitude, latitude), ...].""" + (lon1, lat1), (lon2, lat2) = route[:-1].T, route[1:].T + steps = np.hypot((lon2 - lon1) * np.cos(np.radians((lat1 + lat2) / 2)), lat2 - lat1) * METERS_PER_DEGREE + return np.r_[0.0, np.cumsum(steps)] + + +def along_path(path: np.ndarray, point) -> tuple[float, float]: + """(meters along a (longitude, latitude) path to a point, and the point's distance from the path): + the path up to the start of the nearest segment, then straight to the point.""" + local, p = local_meters(path, path[0]), local_meters(point, path[0]) + segment, _, lateral = nearest_segment(local[:-1], local[1:], p) + return float(cumulative_distance(path)[segment] + np.linalg.norm(local_meters(point, path[segment]))), lateral + + +class RouteTracker: + """Progress of the ego along a route, in meters, and the map pose at that progress.""" + + def __init__(self, route: np.ndarray): + self.route = np.asarray(route, dtype=np.float64).reshape((-1, 2)) + self.path = local_meters(self.route, self.route[0]) + self.distance = cumulative_distance(self.route) + self.progress = -np.inf # the first fix may land anywhere on the route + + def project(self, longitude: float, latitude: float) -> float: + """Advance to the nearest spot on the route within the window ahead of the previous progress.""" + near = np.flatnonzero((self.distance[1:] >= self.progress + PROJECTION_WINDOW[0]) & (self.distance[:-1] <= self.progress + PROJECTION_WINDOW[1])) + if not len(near): # only before the first fix, when the progress is still -inf + near = np.arange(len(self.path) - 1) + point = local_meters([longitude, latitude], self.route[0]) + segment, along, _ = nearest_segment(self.path[near], self.path[near + 1], point) + segment = near[segment] + self.progress = max(self.progress, self.distance[segment] + along * (self.distance[segment + 1] - self.distance[segment])) + return self.progress + + def pose(self, progress: float) -> tuple[float, float, float]: + """(longitude, latitude, bearing) on the route at a progress, headed along the +-50m chord.""" + ends = np.clip([progress - HEADING_HALF_WINDOW, progress, progress + HEADING_HALF_WINDOW], 0, self.distance[-1]) + (lon0, lon, lon1), (lat0, lat, lat1) = (np.interp(ends, self.distance, self.route[:, axis]) for axis in (0, 1)) + bearing = np.degrees(np.arctan2((lon1 - lon0) * np.cos(np.radians(lat0)), lat1 - lat0)) % 360 + return float(lon), float(lat), float(bearing) + + def snap(self, longitude: float, latitude: float) -> tuple[float, float, float]: + return self.pose(self.project(longitude, latitude)) diff --git a/openpilot/selfdrive/navigation/tests/test_navigation.py b/openpilot/selfdrive/navigation/tests/test_navigation.py new file mode 100644 index 0000000000..4db576018d --- /dev/null +++ b/openpilot/selfdrive/navigation/tests/test_navigation.py @@ -0,0 +1,485 @@ +import gzip +import re +import struct +import tempfile +import threading +import time +import unittest +from contextlib import closing, contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import numpy as np +import requests + +import openpilot.cereal.messaging as messaging +from openpilot.common.prefix import OpenpilotPrefix +from openpilot.selfdrive.navigation.mapsd import MapsD +from openpilot.selfdrive.navigation.navd import REROUTE_COUNTER_MIN, ROUTE_RESEND_PERIOD, RouteEngine, set_banner +from openpilot.selfdrive.navigation.pmtiles import PMTiles, decode_directory, tile_id +from openpilot.selfdrive.navigation.renderer import MAJOR, MEDIUM, MINOR, SIZE, STREET, TRACK, MapRenderer, draw_segments, mercator, project, stroke_widths +from openpilot.selfdrive.navigation.route import RouteTracker +from openpilot.selfdrive.navigation.tiles import RemoteTiles +from openpilot.selfdrive.navigation.vector_tile import decode_lines, decode_roads + +METER = 1 / 111319.49079327358 # degrees of latitude + + +def draw_line(image, points, intensity, width, **kwargs): + draw_segments(image, points[:-1], points[1:], width / 2, intensity, **kwargs) + + +def varint(value): + output = bytearray() + while value > 127: + output.append((value & 127) | 128) + value >>= 7 + output.append(value) + return bytes(output) + + +def field(number, value): + if isinstance(value, bytes): + return varint(number << 3 | 2) + varint(len(value)) + value + return varint(number << 3) + varint(value) + + +def road_tile(crossing=None): + # One vertical line, from (2048, 0) to (2048, 4096), and one non-road layer. With crossing='bridge' or 'tunnel', + # a horizontal line from (1024, 2048) to (3072, 2048) at that level. + geometry = b''.join(varint(v) for v in [9, 4096, 0, 10, 0, 8192]) + feature = field(2, b'\x00\x00') + field(3, 2) + field(4, geometry) + roads = field(15, 2) + field(1, b'roads') + field(2, feature) + if crossing is not None: + geometry = b''.join(varint(v) for v in [9, 2048, 4096, 10, 4096, 0]) + roads += field(2, field(2, b'\x00\x00\x01\x01') + field(3, 2) + field(4, geometry)) + roads += field(3, b'kind') + field(3, f'is_{crossing}'.encode()) + field(4, field(1, b'minor_road')) + field(4, field(7, 1)) + field(5, 4096) + return field(3, field(1, b'buildings')) + field(3, roads) + + +def make_archive(payload=None, leaf=False, run=1, max_zoom=0): + """A PMTiles archive with one zoom-zero tile, in the root directory or in a leaf directory.""" + tile = gzip.compress(road_tile() if payload is None else payload) + directory = gzip.compress(varint(1) + varint(0) + varint(run) + varint(len(tile)) + varint(1)) + child = directory if leaf else b'' + root = gzip.compress(varint(1) + varint(0) + varint(0) + varint(len(child)) + varint(1)) if leaf else directory + metadata = gzip.compress(b'{"vector_layers":[{"id":"roads"}]}') + header = bytearray(127) + header[:8] = b'PMTiles\x03' + offsets = [127, 127 + len(root), 127 + len(root) + len(metadata), 127 + len(root) + len(metadata) + len(child)] + struct.pack_into('<11Q', header, 8, offsets[0], len(root), offsets[1], len(metadata), offsets[2], len(child), offsets[3], len(tile), 1, 1, 1) + header[96:102] = bytes([1, 2, 2, 1, 0, max_zoom]) + return bytes(header + root + metadata + child + tile) + + +@contextmanager +def local_archive(data): + """A PMTiles reader over in-memory bytes, bypassing the tile server.""" + source = MagicMock(size=len(data)) + source.read.side_effect = lambda offset, length: data[offset:offset + length] + with closing(PMTiles(source)) as archive: + yield archive + + +def mapbox_response(steps): + response = MagicMock(status_code=200) + response.json.return_value = {'routes': [{'legs': [{'steps': steps}]}]} + return response + + +def route_engine(sm, pm, values): + params = MagicMock() + params.get.side_effect = values.get + params.remove.side_effect = lambda key: values.pop(key, None) + with patch('openpilot.selfdrive.navigation.navd.Api') as api: + api.return_value.get_token.return_value = 'test-token' + return RouteEngine(sm, pm, params=params) + + +class RangeHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.server.requests.append(dict(self.headers)) + if self.headers.get('If-Match', self.server.etag) != self.server.etag: + self.send_error(412) + return + if self.server.ignore_range: + self.send_response(200) + self.end_headers() + return + offset, end = map(int, re.fullmatch(r'bytes=(\d+)-(\d+)', self.headers['Range']).groups()) + data = self.server.archive[offset:end + 1] + self.send_response(206) + self.send_header('Content-Range', f'bytes {offset}-{end}/{len(self.server.archive)}') + self.send_header('Content-Length', str(len(data))) + self.send_header('ETag', self.server.etag) + self.end_headers() + self.wfile.write(data) + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 # stdlib override + pass + + +class TileServerCase(unittest.TestCase): + """Serves a leaf-directory archive over HTTP range requests, with the comma API and tile cache stubbed.""" + + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.cache = Path(self.directory.name) / 'navigation' / 'tiles' + self.server = ThreadingHTTPServer(('127.0.0.1', 0), RangeHandler) + self.server.archive = make_archive(leaf=True) + self.server.etag = '"test-version"' + self.server.ignore_range = False + self.server.requests = [] + threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True).start() + self.addCleanup(self.server.server_close) + self.addCleanup(self.server.shutdown) + self.api = MagicMock(dongle_id='test-dongle') + self.api.get_token.return_value = 'device-token' + self.url = f'http://127.0.0.1:{self.server.server_port}/planet.pmtiles?sig=secret' + self.api.get.return_value.json.return_value = {'url': self.url, 'expires': time.time() + 86400} # noqa: TID251 + for name, value in [('Api', MagicMock(return_value=self.api)), ('Params', MagicMock()), ('PC', True)]: + self.enterContext(patch(f'openpilot.selfdrive.navigation.tiles.{name}', value)) + self.enterContext(patch('openpilot.selfdrive.navigation.tiles.Paths.comma_home', return_value=self.directory.name)) + + +class TestEndToEnd(TileServerCase): + def test_navd_to_map_frames(self): + """navd routes a destination over messaging; mapsd fetches tiles over HTTP and renders map frames.""" + destination = {'latitude': 0.01, 'longitude': 0} + self.enterContext(patch('requests.get', return_value=mapbox_response([ + {'geometry': {'coordinates': [[0, -0.01], [0, 0.01]]}, 'distance': 2224, 'duration': 100, 'bannerInstructions': []}]))) + with OpenpilotPrefix(): + pm = messaging.PubMaster(['navInstruction', 'navRoute', 'gpsLocationExternal']) + sm = messaging.SubMaster(['gpsLocation', 'gpsLocationExternal', 'navRoute']) + values = {'NavDestination': dict(destination)} + navd = route_engine(messaging.SubMaster(['gpsLocation', 'gpsLocationExternal']), pm, values) + mapsd = MapsD() + self.addCleanup(mapsd.close) + + def frame(fix=True): + gps = messaging.new_message('gpsLocationExternal', valid=True) + gps.gpsLocationExternal.hasFix = fix + pm.send('gpsLocationExternal', gps) + navd.update() + sm.update(0) + return mapsd.update(sm) + + image, timestamp = frame() + # The route is centered over the white road, with the ego arrow three quarters down. + self.assertEqual((image[128, 128], image[128, 131], image[188, 128], image[195, 128]), (128, 255, 255, 0)) + self.assertEqual(timestamp, sm.logMonoTime['gpsLocationExternal']) + fetched = len(self.server.requests) + tracker = mapsd.tracker + navd.send_route() # the same route again, as a restarted mapsd would get it + frame() + self.assertIs(mapsd.tracker, tracker) # progress along it is kept + values.pop('NavDestination') + self.assertFalse(frame()[0].any()) # black without a route + values['NavDestination'] = dict(destination) + self.assertEqual(frame()[0][128, 128], 128) + self.assertEqual(len(self.server.requests), fetched) # the second route is served from the cache + image, timestamp = frame(fix=False) + self.assertFalse(image.any() or timestamp) # black, not stale, without a fix + self.api.get.assert_called_once() + self.assertTrue(all('Range' in headers for headers in self.server.requests)) + self.assertTrue(all(headers['If-Match'] == self.server.etag for headers in self.server.requests[1:])) + self.assertTrue(all(b'secret' not in p.read_bytes() for p in self.cache.iterdir())) + # The archive is replaced on the server and the cache evicted: the next fetch fails, the map goes black, + # and after a delay a fresh reader starts over on the new version. + self.server.etag = '"new-version"' + for cached in self.cache.iterdir(): + cached.unlink() + values.pop('NavDestination') + frame() + values['NavDestination'] = dict(destination) + image, timestamp = frame() + self.assertFalse(image.any() or timestamp) + fetched = len(self.server.requests) + self.assertFalse(frame()[1]) # not retried yet + self.assertEqual(len(self.server.requests), fetched) + mapsd.retry_at = 0 + self.assertEqual(frame()[0][128, 128], 128) + self.assertEqual((self.cache / 'version').read_text().split()[0], '"new-version"') + + +class TestRemoteTiles(TileServerCase): + def test_shared_cache_and_refresh(self): + with closing(PMTiles(RemoteTiles())) as archive: + archive.source.expires = 0 # the signed URL is refreshed before the next fetch + self.assertEqual(archive.get(0, 0, 0), road_tile()) + self.assertEqual(archive.get(0, 0, 0), road_tile()) + self.assertEqual(self.api.get.call_count, 2) + count = len(self.server.requests) + with closing(PMTiles(RemoteTiles())) as archive: + self.assertEqual(archive.get(0, 0, 0), road_tile()) + # A second reader revalidates only the header; directories and tiles are shared on disk. + self.assertEqual(len(self.server.requests), count + 1) + with patch('openpilot.selfdrive.navigation.tiles.CACHE_LIMIT', 1), closing(RemoteTiles()) as source: + source.read(1, 2) # an uncached range is downloaded, then the cache is trimmed to the limit + self.assertEqual([p.name for p in self.cache.iterdir()], ['version']) + + def test_prefetch_merges_nearby_ranges(self): + with patch('openpilot.selfdrive.navigation.tiles.MERGE_GAP', 16), closing(RemoteTiles()) as source: + count = len(self.server.requests) + source.prefetch([(20, 10), (0, 10), (60, 10), (20, 10)]) + self.assertEqual(len(self.server.requests), count + 2) # 0-29 in one request, 60-69 in another + self.assertEqual(self.server.requests[-2]['Range'], 'bytes=0-29') + for offset in (0, 20, 60): + self.assertEqual(source.read(offset, 10), self.server.archive[offset:offset + 10]) + self.assertEqual(len(self.server.requests), count + 2) + + def test_failures(self): + with closing(PMTiles(RemoteTiles())) as archive: + self.server.etag = '"new-version"' + with self.assertRaisesRegex(OSError, '412'): # the archive changed under an open reader + archive.get(0, 0, 0) + with closing(PMTiles(RemoteTiles())) as archive: # a new reader starts over on the new version + self.assertEqual(archive.get(0, 0, 0), road_tile()) + self.server.ignore_range = True + with self.assertRaisesRegex(OSError, '200'): + RemoteTiles() + self.api.get.return_value.raise_for_status.side_effect = requests.HTTPError(self.url) + with self.assertRaisesRegex(OSError, '^Unable to fetch map tiles$'): # the signed URL stays out of the error + RemoteTiles() + + +class TestRouteEngine(unittest.TestCase): + def setUp(self): + self.pm = MagicMock() + self.values = {'NavDestination': {'latitude': 0, 'longitude': 0.002}, 'LanguageSetting': 'main_en'} + self.sm = MagicMock() + self.sm.valid = dict.fromkeys(('gpsLocation', 'gpsLocationExternal'), True) + self.sm.logMonoTime = dict.fromkeys(self.sm.valid, time.monotonic_ns()) + self.sm.recv_time = dict.fromkeys(self.sm.valid, time.monotonic()) + self.sm.__getitem__.return_value = SimpleNamespace(hasFix=True, latitude=0, longitude=0, bearingDeg=90) + self.engine = route_engine(self.sm, self.pm, self.values) + self.engine.last_position, self.engine.last_bearing = (0, 0), 90 + distance = 111.195 # Mapbox's length of 0.001 degrees at the equator + self.response = mapbox_response([ + {'geometry': {'coordinates': [[0, 0], [0.0005, 0], [0.001, 0]]}, 'distance': distance, 'duration': 10, + 'bannerInstructions': [{'distanceAlongGeometry': 100, 'primary': {'text': 'Turn right', 'type': 'turn', 'modifier': 'right'}}]}, + {'geometry': {'coordinates': [[0.001, 0], [0.002, 0]]}, 'distance': distance, 'duration': 10, 'bannerInstructions': []}, + ]) + + def last_message(self, service): + return next(call.args[1] for call in reversed(self.pm.send.call_args_list) if call.args[0] == service) + + def load_route(self): + with patch('requests.get', return_value=self.response) as get: + self.engine.calculate_route((0.002, 0)) + self.engine.send_route() + return get + + def test_request_and_instruction(self): + get = self.load_route() + self.assertEqual(get.call_args.args[0], 'https://api.commadotai.com/v1/nav/directions/v5/mapbox/driving-traffic/0,0;0.002,0') + options = get.call_args.kwargs['params'] + self.assertEqual((options['access_token'], options['bearings'], options['language']), ('test-token', '90,90;', 'en')) + self.assertEqual(len(self.last_message('navRoute').navRoute.coordinates), 5) + self.engine.send_instruction() + message = self.last_message('navInstruction') + self.assertTrue(message.valid) + instruction = message.navInstruction.to_dict() + self.assertEqual((instruction['maneuverPrimaryText'], instruction['maneuverModifier'], instruction['timeRemaining']), ('Turn right', 'right', 20)) + self.engine.last_position = (0.0007, 0) # on the step's second segment + self.engine.send_instruction() + self.assertAlmostEqual(self.last_message('navInstruction').navInstruction.timeRemaining, 13, delta=0.05) + self.engine.last_position = (0.001, 0) # at the maneuver + self.engine.send_instruction() + self.assertAlmostEqual(self.last_message('navInstruction').navInstruction.maneuverDistance, 0, delta=0.01) + + def test_banner_selection(self): + banners = [{'distanceAlongGeometry': 100, 'primary': {'text': 'Turn right', 'type': 'turn', 'modifier': 'right'}}, + {'distanceAlongGeometry': 50, 'primary': {'text': 'Turn right'}, 'secondary': {'text': 'onto Main St'}}] + # The last banner still ahead of the ego, else the first: 120 m, 70 m and 30 m from the maneuver. + for distance, expected in [(120, ('Turn right', '', False)), (70, ('Turn right', '', True)), (30, ('Turn right', 'onto Main St', True))]: + instruction = messaging.new_message('navInstruction').navInstruction + set_banner(instruction, banners, distance) + self.assertEqual((instruction.maneuverPrimaryText, instruction.maneuverSecondaryText, instruction.showFull), expected) + + def test_route_sent_on_change_and_periodically(self): + sent = lambda: sum(call.args[0] == 'navRoute' for call in self.pm.send.call_args_list) # noqa: E731 + self.sm.frame = 1 + with patch('requests.get', return_value=self.response): + self.engine.update() # the route to a new destination is sent once + self.engine.update() + self.assertEqual(sent(), 1) + self.sm.frame = ROUTE_RESEND_PERIOD # and again periodically, for a mapsd that started after it + self.engine.update() + self.assertEqual(sent(), 2) + + def test_maneuvers_and_rerouting(self): + self.load_route() + self.engine.last_position = (0, 0.001) # 111 m off route: reroute after a few consecutive misses + for _ in range(REROUTE_COUNTER_MIN): + self.assertFalse(self.engine.should_recompute()) + self.assertTrue(self.engine.should_recompute()) + with patch('requests.get') as get: + self.engine.recompute_route(gps_ok=False) # but not while the GPS drifts in a tunnel + get.assert_not_called() + with patch('requests.get', return_value=self.response) as get: + self.engine.recompute_route(gps_ok=True) + self.engine.reroute_counter = REROUTE_COUNTER_MIN # still off route, but an immediate second recompute is held back + self.engine.recompute_route(gps_ok=True) + self.assertEqual(get.call_count, 1) + self.engine.last_position = (0.0012, 0) # past the first maneuver + self.engine.send_instruction() + self.assertEqual(self.engine.step_idx, 1) + self.engine.last_position = (0.00215, 0) # 17 m past the destination: arrived, but the route is kept nearby + self.engine.send_instruction() + self.assertIsNotNone(self.engine.route) + self.engine.last_position = (0.0024, 0) # driving away from the destination + self.engine.send_instruction() + self.assertIsNone(self.engine.route) + self.assertNotIn('NavDestination', self.values) + + def test_failed_request_clears_route(self): + self.load_route() + self.response.raise_for_status.side_effect = requests.HTTPError('test failure') + self.load_route() + self.assertIsNone(self.engine.route) + self.assertEqual(len(self.last_message('navRoute').navRoute.coordinates), 0) + self.engine.send_instruction() + self.assertFalse(self.last_message('navInstruction').valid) + + def test_gps_source(self): + fixes = {'gpsLocation': SimpleNamespace(hasFix=True, latitude=1, longitude=1, bearingDeg=180), + 'gpsLocationExternal': SimpleNamespace(hasFix=True, latitude=2, longitude=2, bearingDeg=0)} + self.sm.__getitem__.side_effect = fixes.get + self.sm.logMonoTime = {'gpsLocation': 2, 'gpsLocationExternal': 1} # replayed logs are judged by receipt time + with patch('requests.get', return_value=self.response): + self.engine.update() + self.assertEqual((self.engine.last_position, self.engine.last_bearing), ((1, 1), 180)) # the newest fix wins + self.sm.valid['gpsLocation'] = False + self.engine.update() + self.assertEqual(self.engine.last_position, (2, 2)) # unless it is invalid + fixes['gpsLocationExternal'] = SimpleNamespace(hasFix=True, latitude=3, longitude=3, bearingDeg=0) + self.sm.recv_time = dict.fromkeys(self.sm.valid, time.monotonic() - 3) # or stale + self.engine.update() + self.assertEqual(self.engine.last_position, (2, 2)) + + +class TestTiles(unittest.TestCase): + def test_pmtiles_format(self): + self.assertEqual([tile_id(1, x, y) for x, y in [(0, 0), (0, 1), (1, 1), (1, 0)]], [1, 2, 3, 4]) + self.assertEqual(sorted(tile_id(3, x, y) for x in range(8) for y in range(8)), list(range(21, 85))) + # IDs 5, 10; runs 3, 1; lengths 4, 6; the second offset follows the first blob. + data = bytes([2, 5, 5, 3, 1, 4, 6, 1, 0]) + self.assertEqual(decode_directory(data), ([5, 10], [(3, 4, 0), (1, 6, 4)])) + for invalid in (b'\x80', b'\x01\x00\x01\x01\x00', data + b'\x00'): + with self.assertRaises(ValueError): + decode_directory(invalid) + with local_archive(make_archive(run=3, max_zoom=1)) as archive: # the zoom-zero tile is also addressed by IDs 1 and 2 + self.assertEqual(archive.get(1, 0, 1), road_tile()) + self.assertIsNone(archive.get(1, 1, 1)) + with self.assertRaises(ValueError), local_archive(make_archive()[:-1]): + pass + + def test_vector_tiles(self): + roads = decode_roads(road_tile()) + self.assertEqual((len(roads), roads[0].kind), (1, 'minor_road')) + np.testing.assert_array_equal(roads[0].points, [[0.5, 0], [0.5, 1]]) + lines = decode_lines([9, 20, 20, 10, 10, 0, 9, 9, 9, 10, 0, 10]) + np.testing.assert_array_equal(lines[0], [[10, 10], [15, 10]]) + np.testing.assert_array_equal(lines[1], [[10, 5], [10, 10]]) + for data in (b'\x1a\xff', b'\x00', b'\x1a\x02\x0a\x04'): + with self.assertRaises(ValueError): + decode_roads(data) + for commands in ([10, 1, 1], [9, 0], [9, 0, 0, 15]): + with self.assertRaises(ValueError): + decode_lines(commands) + + +class TestRendering(unittest.TestCase): + def test_projection(self): + # At the equator, 64m ahead is the image center and the car is 64px below it, at 1m per pixel. + points = mercator(np.array([[0, 0], [0, 64 * METER], [64 * METER, 0]])) + np.testing.assert_allclose(project(points, 0, 0, 0), [[128, 192], [128, 128], [192, 192]], atol=1e-5) + np.testing.assert_allclose(project(points, 0, 0, 90), [[128, 192], [64, 192], [128, 128]], atol=1e-5) + pixels = project(mercator(np.array([[-179.999, 0]])), 179.999, 0, 90) # across the antimeridian + self.assertAlmostEqual(pixels[0, 1], 192 - 222.6389816, places=5) + + def test_strokes(self): + image = np.zeros((SIZE, SIZE), dtype=np.uint8) + draw_line(image, np.array([[-1000, 128], [1000, 128]]), 255, 8) + self.assertTrue(np.all(image[128] == 255)) + draw_line(image, np.array([[128, 0], [128, 256]]), 128, 5, overlay=True) # the grey route replaces the white road + self.assertEqual((image[128, 128], image[128, 131]), (128, 255)) + before = image.copy() + draw_line(image, np.array([[-1000, -1000], [-500, -500]]), 255, 5) + np.testing.assert_array_equal(image, before) + draw_line(image, np.array([[40, 40], [40, 40]]), 255, 5) # a zero-length segment is still a dot + self.assertEqual(image[40, 40], 255) + + def test_crossings(self): + route = np.array([[0.01, 0], [0.01, 0.01]]) # off to the side, out of the way of the probes + images = {} + for crossing in ('bridge', 'tunnel'): + with local_archive(make_archive(road_tile(crossing))) as archive: + images[crossing] = MapRenderer(archive).render(0, -40 * METER, 0, route)[0] # the crossing 40m ahead + # The horizontal road crosses the vertical one 40px above the ego, at row 152. A bridge severs the road beneath + # just outside its own stroke; a tunnel passes underneath untouched. The cut has butt caps, so the + # bridge's own end is not wrapped in black. + self.assertEqual((images['bridge'][146, 128], images['bridge'][152, 128], images['bridge'][152, 200], images['bridge'][140, 128]), (0, 255, 255, 255)) + self.assertEqual((images['tunnel'][146, 128], images['tunnel'][152, 200]), (255, 255)) + # The ground road cuts over the tunnel beneath it, and the tunnel is drawn without its casing. + self.assertEqual((images['tunnel'][152, 133], images['tunnel'][147, 200]), (0, 0)) + + def test_road_widths(self): + # Pinned at the style zoom of 1 m per pixel: 16.26 at the equator and 15.26 at 60 degrees. Tunnels have no casing. + styles, levels = np.array([MAJOR, MEDIUM, STREET, MINOR, TRACK, STREET]), np.array([0, 0, 0, 0, 0, -1]) + np.testing.assert_allclose(stroke_widths(16.256, styles, levels), [19.02, 15.48, 9.86, 6.70, 7.02, 7.89], atol=0.01) + np.testing.assert_allclose(stroke_widths(15.256, styles, levels), [13.52, 10.78, 6.39, 4.16, 4.58, 4.62], atol=0.01) + + def test_route_tiles(self): + route = np.array([[0, -0.01], [0, 0.01]]) # 2.2 km north along the prime meridian + with local_archive(make_archive(max_zoom=15)) as archive: + renderer = MapRenderer(archive) + tiles = renderer.route_tiles(route) + # Both columns beside the meridian, and the rows within the view radius of the route's ends. + self.assertEqual({x for x, _ in tiles}, {16383, 16384}) + self.assertEqual({y for _, y in tiles}, set(range(16382, 16386))) + + def test_validity_and_display(self): + route = np.array([[0, -0.01], [0, 0.01]]) + with local_archive(make_archive()) as archive: + renderer = MapRenderer(archive) + self.assertTrue(renderer.render(0, 0, 0, route)[1]) + self.assertFalse(renderer.render(0.01, 0, 0, route)[1]) # no road near the ego + with patch.object(archive, 'get', return_value=None): # a missing tile + image, valid = MapRenderer(archive).render(0, 0, 0, route) + self.assertFalse(valid) + self.assertEqual(image[128, 128], 128) # the route is still drawn + with local_archive(make_archive(field(3, field(1, b'buildings')))) as archive: # tiles without roads + self.assertFalse(MapRenderer(archive).render(0, 0, 0, route)[1]) + + +class TestRouteTracking(unittest.TestCase): + def test_snap_heading_and_window(self): + m = METER + tracker = RouteTracker(np.array([[0, 0], [0, 200 * m], [200 * m, 200 * m]])) # north 200m, then east 200m + longitude, latitude, bearing = tracker.snap(3 * m, 100 * m) # 3m right of the first leg + self.assertAlmostEqual(tracker.progress, 100, delta=0.5) + self.assertAlmostEqual(longitude, 0) # lateral error removed + self.assertAlmostEqual(latitude, 100 * m) + self.assertAlmostEqual(bearing, 0, places=3) + self.assertAlmostEqual(tracker.snap(0, 200 * m)[2], 45, places=2) # at the corner the +-50m chord is mid-turn + self.assertAlmostEqual(tracker.snap(0, 150 * m)[2], 45, places=2) # progress never decreases + tracker.snap(100 * m, 200 * m) # progress 300: the window ahead no longer includes the first leg + self.assertAlmostEqual(tracker.snap(150 * m, 200 * m)[0], 150 * m) # so the window's offset is applied + self.assertAlmostEqual(tracker.snap(200 * m, 200 * m)[2], 90, places=2) # the chord clips at the route's end + # Out and back along the same road: a fix near the far leg stays on the outbound leg within the window, + # while a fresh tracker searches anywhere and lands on the return leg. + route = np.array([[0, 0], [0, 1000 * m], [10 * m, 1000 * m], [10 * m, 0]]) + tracker = RouteTracker(route) + tracker.snap(1 * m, 100 * m) + self.assertAlmostEqual(tracker.snap(9 * m, 150 * m)[2], 0, places=3) + self.assertAlmostEqual(tracker.progress, 150, delta=0.5) + self.assertAlmostEqual(RouteTracker(route).snap(9 * m, 150 * m)[2], 180, places=3) + + +if __name__ == '__main__': + unittest.main() diff --git a/openpilot/selfdrive/navigation/tiles.py b/openpilot/selfdrive/navigation/tiles.py new file mode 100644 index 0000000000..c1196e3328 --- /dev/null +++ b/openpilot/selfdrive/navigation/tiles.py @@ -0,0 +1,117 @@ +"""Byte ranges of the hosted PMTiles basemap, cached on disk.""" +import re +import time +from pathlib import Path + +import requests + +from openpilot.common.api import Api +from openpilot.common.hardware import PC +from openpilot.common.hardware.hw import Paths +from openpilot.common.params import Params +from openpilot.selfdrive.navigation.pmtiles import HEADER_LENGTH + +CACHE_LIMIT = 64 * 1024 * 1024 +MERGE_GAP = 256 * 1024 # tiles this close together in the archive are fetched in one range request + + +class RemoteTiles: + def __init__(self): + self.cache = Path(Paths.comma_home() if PC else '/data') / 'navigation' / 'tiles' + self.cache.mkdir(parents=True, exist_ok=True, mode=0o700) + self.session = requests.Session() + self.api = Api(Params().get('DongleId')) + self.url = '' + self.expires = 0 + self.etag = None + self.size = 0 + version = self.cache / 'version' + try: + header = self._fetch(0, HEADER_LENGTH) # learns the archive's ETag and size + if not version.exists() or version.read_text() != f'{self.etag} {self.size}': + for cached in self.cache.iterdir(): # a new archive has new offsets + cached.unlink() + version.write_text(f'{self.etag} {self.size}') + self._store(0, header) + except Exception: + self.close() + raise + + def _fetch(self, offset: int, length: int) -> bytes: + try: + if time.time() >= self.expires - 60: # noqa: TID251 - SAS expiry is a Unix timestamp + response = self.api.get(f'v1/{self.api.dongle_id}/nav/tiles', access_token=self.api.get_token(), timeout=10) + response.raise_for_status() + metadata = response.json() + self.url, self.expires = metadata['url'], metadata['expires'] + headers = {'Range': f'bytes={offset}-{offset + length - 1}', 'Accept-Encoding': 'identity'} + if self.etag is not None: + headers['If-Match'] = self.etag + with self.session.get(self.url, headers=headers, timeout=10, stream=True) as response: + if response.status_code != 206: + raise OSError(f'Map tile range request failed ({response.status_code})') + match = re.fullmatch(r'bytes (\d+)-(\d+)/(\d+)', response.headers.get('Content-Range', '')) + if match is None or tuple(map(int, match.groups()[:2])) != (offset, offset + length - 1): + raise OSError('Invalid map tile range response') + size = int(match[3]) + etag = response.headers.get('ETag') + if not etag or (self.etag is not None and (etag != self.etag or size != self.size)): + raise OSError('Map archive changed during read') + data = bytearray() + for chunk in response.iter_content(chunk_size=min(length + 1, 65536)): + data.extend(chunk) + if len(data) > length: + raise OSError('Oversized map tile range response') + if len(data) != length: + raise OSError('Truncated map tile range response') + self.size, self.etag = size, etag + return bytes(data) + except (requests.RequestException, ValueError, KeyError, TypeError, RuntimeError): + # requests exceptions contain the signed URL; keep it out of logs. + raise OSError('Unable to fetch map tiles') from None + + def _store(self, offset: int, data: bytes): + (self.cache / f'{offset}-{len(data)}').write_bytes(data) + + def _trim(self): + files = [(cached.stat().st_mtime, cached.stat().st_size, cached) for cached in self.cache.iterdir() if re.fullmatch(r'\d+-\d+', cached.name)] + size = sum(cached_size for _, cached_size, _ in files) + for _, cached_size, cached in sorted(files): + if size <= CACHE_LIMIT: + break + cached.unlink(missing_ok=True) + size -= cached_size + + def read(self, offset: int, length: int) -> bytes: + try: + data = (self.cache / f'{offset}-{length}').read_bytes() + if len(data) == length: + return data + except FileNotFoundError: + pass + data = self._fetch(offset, length) + self._store(offset, data) + self._trim() + return data + + def prefetch(self, ranges): + """Cache (offset, length) ranges ahead of reads, fetching neighboring ones in a single request.""" + missing = sorted((offset, length) for offset, length in set(ranges) if not (self.cache / f'{offset}-{length}').exists()) + if not missing: + return + groups = [[missing[0]]] + for offset, length in missing[1:]: + last_offset, last_length = groups[-1][-1] + if offset - (last_offset + last_length) <= MERGE_GAP: + groups[-1].append((offset, length)) + else: + groups.append([(offset, length)]) + for group in groups: + start, end = group[0][0], group[-1][0] + group[-1][1] + data = self._fetch(start, end - start) + for offset, length in group: + self._store(offset, data[offset - start:offset - start + length]) + self._trim() + + def close(self): + self.session.close() diff --git a/openpilot/selfdrive/navigation/vector_tile.py b/openpilot/selfdrive/navigation/vector_tile.py new file mode 100644 index 0000000000..06439efa44 --- /dev/null +++ b/openpilot/selfdrive/navigation/vector_tile.py @@ -0,0 +1,133 @@ +"""Decode road polylines from Protomaps MVT tiles without a protobuf runtime.""" +from dataclasses import dataclass + +import numpy as np + +from openpilot.selfdrive.navigation.pmtiles import read_varint + + +@dataclass +class Road: + points: np.ndarray # fractions of the tile, x right / y down + kind: str + detail: str + tunnel: bool + bridge: bool + + +def fields(data: bytes): + offset = 0 + while offset < len(data): + tag, offset = read_varint(data, offset) + number, wire = tag >> 3, tag & 7 + if number == 0: + raise ValueError("Invalid protobuf field") + if wire == 0: + value, offset = read_varint(data, offset) + elif wire in (1, 2, 5): + if wire == 2: + length, offset = read_varint(data, offset) + else: + length = 8 if wire == 1 else 4 + if offset + length > len(data): + raise ValueError("Truncated protobuf field") + value = data[offset:offset + length] + offset += length + else: + raise ValueError(f"Unsupported protobuf wire type: {wire}") + yield number, wire, value + + +def integers(data: bytes) -> list[int]: + result = [] + offset = 0 + while offset < len(data): + value, offset = read_varint(data, offset) + result.append(value) + return result + + +def unzigzag(value: int) -> int: + return (value >> 1) ^ -(value & 1) + + +def decode_value(data: bytes): + """A string or boolean layer value. Other types are unused by the roads layer and decode to None.""" + for number, wire, value in fields(data): + if number == 1 and wire == 2: + return value.decode('utf-8') + if number == 7 and wire == 0: + return bool(value) + return None + + +def decode_lines(commands: list[int]) -> list[np.ndarray]: + lines = [] + line = [] + x = y = offset = 0 + while offset < len(commands): + command = commands[offset] + offset += 1 + kind, count = command & 7, command >> 3 + if kind not in (1, 2) or count == 0 or offset + 2 * count > len(commands): + raise ValueError("Invalid MVT line geometry") + if kind == 1 and count != 1: + raise ValueError("LineString MoveTo must contain one point") + if kind == 2 and not line: + raise ValueError("LineTo before MoveTo") + for _ in range(count): + x += unzigzag(commands[offset]) + y += unzigzag(commands[offset + 1]) + offset += 2 + if kind == 1: + if len(line) >= 2: + lines.append(np.array(line, dtype=np.float64)) + line = [] + line.append((x, y)) + if len(line) >= 2: + lines.append(np.array(line, dtype=np.float64)) + return lines + + +def decode_roads(data: bytes) -> list[Road]: + roads = [] + for number, wire, layer in fields(data): + if number != 3 or wire != 2: + continue + layer_fields = list(fields(layer)) + name = next((v.decode('utf-8') for n, w, v in layer_fields if n == 1 and w == 2), '') + if name != 'roads': + continue + version = next((v for n, w, v in layer_fields if n == 15 and w == 0), 1) + extent = next((v for n, w, v in layer_fields if n == 5 and w == 0), 4096) + if version not in (1, 2) or extent <= 0: + raise ValueError("Unsupported MVT roads layer") + keys = [v.decode('utf-8') for n, w, v in layer_fields if n == 3 and w == 2] + values = [decode_value(v) for n, w, v in layer_fields if n == 4 and w == 2] + for n, w, feature in layer_fields: + if n != 2 or w != 2: + continue + tags, geometry, geometry_type = [], [], 0 + for fn, fw, fv in fields(feature): + if fn in (2, 4): + target = tags if fn == 2 else geometry + if fw == 2: + target.extend(integers(fv)) + elif fw == 0: + target.append(fv) + elif fn == 3 and fw == 0: + geometry_type = fv + if geometry_type != 2: + continue + if len(tags) % 2: + raise ValueError("Unpaired MVT tags") + properties = {} + for i in range(0, len(tags), 2): + if tags[i] >= len(keys) or tags[i + 1] >= len(values): + raise ValueError("MVT tag index out of bounds") + properties[keys[tags[i]]] = values[tags[i + 1]] + kind = properties.get('kind', '') + if kind in ('highway', 'major_road', 'minor_road', 'other'): + roads.extend(Road(points / extent, kind, properties.get('kind_detail', ''), properties.get('is_tunnel') is True, + properties.get('is_bridge') is True) for points in decode_lines(geometry)) + return roads diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py index 0b52e48bff..32e146b245 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py @@ -83,6 +83,8 @@ class DeveloperLayoutMici(NavScroller): toggle_callback=self._on_alpha_long_enabled, description="Use alpha openpilot longitudinal control instead of stock ACC. This may disable Automatic Emergency " + "Braking (AEB).") + self._navigation_toggle = BigParamControl("navigation (alpha)", "NavigationEnabled", + description="Turn-by-turn navigation with a route map. Set a destination from comma connect.") self._debug_mode_toggle = BigParamControl("ui debug mode", "ShowDebugInfo", toggle_callback=lambda checked: (gui_app.set_show_touches(checked), gui_app.set_show_fps(checked)), description="Show touch locations and the UI frame rate.") @@ -96,6 +98,7 @@ class DeveloperLayoutMici(NavScroller): self._lat_maneuver_toggle, self._alpha_long_toggle, self._debug_mode_toggle, + self._navigation_toggle, ]) # Toggle lists @@ -107,9 +110,10 @@ class DeveloperLayoutMici(NavScroller): ("LateralManeuverMode", self._lat_maneuver_toggle), ("AlphaLongitudinalEnabled", self._alpha_long_toggle), ("ShowDebugInfo", self._debug_mode_toggle), + ("NavigationEnabled", self._navigation_toggle), ) onroad_blocked_toggles = (self._adb_toggle, self._joystick_toggle) - release_blocked_toggles = (self._joystick_toggle, self._long_maneuver_toggle, self._lat_maneuver_toggle, self._alpha_long_toggle) + release_blocked_toggles = (self._joystick_toggle, self._long_maneuver_toggle, self._lat_maneuver_toggle, self._alpha_long_toggle, self._navigation_toggle) engaged_blocked_toggles = (self._long_maneuver_toggle, self._lat_maneuver_toggle, self._alpha_long_toggle) # Hide non-release toggles on release builds diff --git a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py index eecc196946..1a30868426 100644 --- a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -10,6 +10,7 @@ from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer from openpilot.selfdrive.ui.mici.onroad.long_indicator import LongIndicator from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer +from openpilot.selfdrive.ui.mici.onroad.navigation import NavigationPanel, PANEL_WIDTH from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent, TextAlignment, TextAlignmentVertical @@ -169,6 +170,7 @@ class AugmentedRoadView(CameraView): # Bookmark icon with swipe gesture self._bookmark_icon = BookmarkIcon(bookmark_callback) + self._navigation = NavigationPanel() self._model_renderer = ModelRenderer() self._hud_renderer = HudRenderer() self._long_indicator = LongIndicator() @@ -214,11 +216,14 @@ class AugmentedRoadView(CameraView): # Update calibration before rendering self._update_calibration() + alert_to_render, not_animating_out = self._alert_renderer.will_render() + show_navigation = ui_state.navigation_enabled and ui_state.nav_destination_set and alert_to_render is None + # Create inner content area with border padding self._content_rect = rl.Rectangle( self.rect.x, self.rect.y, - self.rect.width - SIDE_PANEL_WIDTH, + self.rect.width - (PANEL_WIDTH if show_navigation else SIDE_PANEL_WIDTH), self.rect.height, ) @@ -240,8 +245,6 @@ class AugmentedRoadView(CameraView): # Fade out bottom of overlays for looks rl.draw_texture_ex(self._fade_texture, rl.Vector2(self._content_rect.x, self._content_rect.y), 0.0, 1.0, rl.WHITE) - alert_to_render, not_animating_out = self._alert_renderer.will_render() - # Hide DMoji when disengaged unless AlwaysOnDM is enabled should_draw_dmoji = (not self._hud_renderer.drawing_top_icons() and (ui_state.status != UIStatus.DISENGAGED or ui_state.always_on_dm)) @@ -265,10 +268,17 @@ class AugmentedRoadView(CameraView): # Custom UI extension point - add custom overlays here # Use self._content_rect for positioning within camera bounds - self._confidence_ball.render(self.rect) + if show_navigation: + self._navigation.render(rl.Rectangle(self._content_rect.x + self._content_rect.width, self.rect.y, PANEL_WIDTH, self.rect.height)) + else: + self._confidence_ball.render(self.rect) self._bookmark_icon.render(self.rect) + def close(self): + self._navigation.close() + super().close() + def _switch_stream_if_needed(self, sm): if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams: v_ego = sm['carState'].vEgo diff --git a/openpilot/selfdrive/ui/mici/onroad/navigation.py b/openpilot/selfdrive/ui/mici/onroad/navigation.py new file mode 100644 index 0000000000..7be9845417 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/onroad/navigation.py @@ -0,0 +1,124 @@ +import numpy as np +import pyray as rl + +from msgq.visionipc import VisionIpcClient +from openpilot.cereal.visionipc import VisionStreamType +from openpilot.common.constants import CV +from openpilot.selfdrive.navigation.helpers import latest_gps_source +from openpilot.selfdrive.navigation.renderer import EGO, ROUTE_INTENSITY, SIZE +from openpilot.selfdrive.ui.mici.onroad.cameraview import CONNECTION_RETRY_INTERVAL +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import FontWeight, TextAlignmentVertical +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label + +PANEL_WIDTH = 261 +STALE_SECONDS = 3 +MARKER_MARGIN = 18 # pixels kept below the ego so its marker stays visible + +# Recolor the model's grayscale frame for people: dim the white roads and make the grey route pop. Only the route's +# exact level (and its neighbors) is recolored, so antialiased road edges passing through that grey stay grey. +MAP_COLORS = np.repeat(np.round(np.arange(256) * 0.45).astype(np.uint8)[:, None], 3, axis=1) +MAP_COLORS[ROUTE_INTENSITY - 1:ROUTE_INTENSITY + 2] = (72, 235, 132) + + +def format_distance(meters: float) -> str: + """m / ft under 1000 m / 300 m, km / mi beyond.""" + if ui_state.is_metric: + return f'{round(meters / 10) * 10} m' if meters < 1000 else f'{meters / 1000:.1f} km' + return f'{round(meters * CV.METER_TO_FOOT / 50) * 50} ft' if meters < 300 else f'{meters * CV.METER_TO_MILE:.1f} mi' + + +class NavigationPanel(Widget): + """Turn instructions around the map frames mapsd publishes for the model.""" + + def __init__(self): + super().__init__() + self._client = VisionIpcClient('mapsd', VisionStreamType.VISION_STREAM_MAP, True) + self._last_connect = 0.0 + self._frame_time = 0.0 + self._texture = None + self._eta_label = UnifiedLabel('', 34, FontWeight.BOLD, rl.WHITE, scroll=True) + self._accent = rl.Color(155, 230, 185, 255) + self._muted = rl.Color(162, 175, 185, 255) + + def _update_state(self): + if not self._client.is_connected(): + if rl.get_time() - self._last_connect < CONNECTION_RETRY_INTERVAL: + return + self._last_connect = rl.get_time() + if not self._client.connect(False) or not self._client.num_buffers: + return + self._unload_texture() + self._texture = rl.load_texture_from_image(rl.Image(None, SIZE, SIZE, 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8)) + frame = self._client.recv(timeout_ms=0) + if frame is not None: + gray = np.frombuffer(frame.data, dtype=np.uint8, count=SIZE * SIZE) + rl.update_texture(self._texture, rl.ffi.cast("void *", rl.ffi.from_buffer(MAP_COLORS[gray]))) + # Without a usable map, mapsd publishes black frames with a zero timestamp. + self._frame_time = rl.get_time() if self._client.timestamp_sof else 0.0 + + @property + def routing(self): + return ui_state.sm.valid['navInstruction'] and ui_state.sm.alive['navInstruction'] + + @property + def map_ready(self): + return self._texture is not None and 0 <= rl.get_time() - self._frame_time < STALE_SECONDS + + def _draw_status(self, rect): + """What the map is waiting on, in place of the map.""" + statuses = ( + ('GPS fix', latest_gps_source(ui_state.sm) is not None), + ('Route', self.routing), + ('Map frames', self.map_ready), + ) + for i, (name, ok) in enumerate(statuses): + gui_label(rl.Rectangle(rect.x + 10, rect.y + 12 + i * 28, rect.width - 20, 26), f'{name}: {"yes" if ok else "no"}', 22, + self._accent if ok else self._muted, FontWeight.MEDIUM, alignment_vertical=TextAlignmentVertical.TOP) + + def _render(self, rect): + x, y = rect.x, rect.y + rl.draw_rectangle_rec(rect, rl.Color(7, 13, 18, 255)) + instruction = ui_state.sm['navInstruction'] + if self.routing: + direction = instruction.maneuverModifier + flip = -1 if 'left' in direction else 1 + # The maneuver arrow on a 1.4x grid, centered in the header's icon box, with round joins for the thick stroke. + if direction in ('straight', ''): + shape = [(0, 14), (0, -12), (-6, -5), (0, -12), (6, -5)] + else: + shape = [(-flip*9, 14), (-flip*9, -4), (-flip*2, -9), (flip*9, -9), (flip*3, -15), (flip*9, -9), (flip*3, -3)] + points = [rl.Vector2(x + 31 + 1.4*px, y + 30 + 1.4*py) for px, py in shape] + for a, b in zip(points[:-1], points[1:], strict=True): + rl.draw_line_ex(a, b, 7, self._accent) + for point in points: + rl.draw_circle_v(point, 3.5, self._accent) + gui_label(rl.Rectangle(x+64, y+11, rect.width-72, 40), format_distance(max(0, instruction.maneuverDistance)), 34, rl.WHITE, FontWeight.BOLD, + alignment_vertical=TextAlignmentVertical.TOP) + else: + gui_label(rl.Rectangle(x+12, y+18, rect.width-24, 30), 'Waiting for route', 26, rl.WHITE, FontWeight.BOLD, + alignment_vertical=TextAlignmentVertical.TOP) + map_rect = rl.Rectangle(x+8, y+61, rect.width-16, rect.height-111) + rl.draw_rectangle_rec(map_rect, rl.BLACK) + if self.map_ready: + # A 1:1 crop of the model's frame, centered horizontally with the ego near the bottom. + width, height = min(map_rect.width, SIZE), min(map_rect.height, SIZE) + source = rl.Rectangle((SIZE - width) // 2, min(SIZE - height, max(0, EGO[1] + MARKER_MARGIN - height)), width, height) + target = rl.Rectangle(map_rect.x + (map_rect.width - width) / 2, map_rect.y + map_rect.height - height, width, height) + rl.draw_texture_pro(self._texture, source, target, rl.Vector2(0, 0), 0, rl.WHITE) + else: + self._draw_status(map_rect) + if self.routing: + seconds = max(0, instruction.timeRemaining) + remaining = max(0, instruction.distanceRemaining) + self._eta_label.set_text(f'{max(1, round(seconds / 60))} min / {format_distance(remaining)}') + self._eta_label.render(rl.Rectangle(x+9, y+rect.height-45, rect.width-18, 40)) + + def _unload_texture(self): + if self._texture: + rl.unload_texture(self._texture) + self._texture = None + + def close(self): + self._unload_texture() diff --git a/openpilot/selfdrive/ui/mici/onroad/torque_bar.py b/openpilot/selfdrive/ui/mici/onroad/torque_bar.py index 3c0b963666..03d7033486 100644 --- a/openpilot/selfdrive/ui/mici/onroad/torque_bar.py +++ b/openpilot/selfdrive/ui/mici/onroad/torque_bar.py @@ -14,7 +14,7 @@ from openpilot.system.ui.widgets import Widget from openpilot.common.filter_simple import FirstOrderFilter # TODO: arc_bar_pts doesn't consider rounded end caps part of the angle span -TORQUE_ANGLE_SPAN = 12.7 +TORQUE_ANGLE_SPAN = 12.7 # degrees on the default 476 px camera view; narrower views beside the navigation panel scale down DEBUG = False @@ -206,7 +206,7 @@ class TorqueBar(Widget): # draw curved line polygon torque bar torque_line_radius = 1200 top_angle = -90 - torque_bg_angle_span = self._torque_line_alpha_filter.x * TORQUE_ANGLE_SPAN + torque_bg_angle_span = self._torque_line_alpha_filter.x * TORQUE_ANGLE_SPAN * rect.width / 476 torque_start_angle = top_angle - torque_bg_angle_span / 2 torque_end_angle = top_angle + torque_bg_angle_span / 2 # centerline radius & center (you already have these values) diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index d7035dab00..0c90a4830c 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -65,6 +65,8 @@ class UIState: "selfdriveState", "longitudinalPlan", "gpsLocationExternal", + "gpsLocation", + "navInstruction", "carOutput", "carControl", "vehicleParameters", @@ -87,6 +89,8 @@ class UIState: self.is_release = self.params.get_bool("IsReleaseBranch") self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM") self.experimental_mode: bool = self.params.get_bool("ExperimentalMode") + self.navigation_enabled: bool = self.params.get_bool("NavigationEnabled") + self.nav_destination_set: bool = self.params.get("NavDestination") is not None self.chestnut_present: bool = False self.chestnut_compiled: bool = chestnut_compiled() self.chestnut_active: bool | None = None @@ -248,6 +252,8 @@ class UIState: self.is_metric = self.params.get_bool("IsMetric") self.always_on_dm = self.params.get_bool("AlwaysOnDM") self.experimental_mode = self.params.get_bool("ExperimentalMode") + self.navigation_enabled = self.params.get_bool("NavigationEnabled") + self.nav_destination_set = self.params.get("NavDestination") is not None self.chestnut_active = self.params.get("ChestnutActive") self.chestnut_loading = self.params.get_bool("ChestnutLoading") self.chestnut_alert = any(self.params.get(k) is not None for k, a in OFFROAD_ALERTS.items() if k.startswith("Offroad_Chestnut") and a["severity"] >= 0) diff --git a/openpilot/system/athena/athenad.py b/openpilot/system/athena/athenad.py index 2b67638776..2e86d245e7 100755 --- a/openpilot/system/athena/athenad.py +++ b/openpilot/system/athena/athenad.py @@ -691,6 +691,20 @@ def cancelUpload(upload_id: str | list[str]) -> dict[str, int | str]: cancelled_uploads.update(cancelled_ids) return {"success": 1} +@dispatcher.add_method +def setNavDestination(latitude: float, longitude: float) -> dict[str, int | str]: + if not (-90 <= latitude <= 90 and -180 <= longitude <= 180): + return {"success": 0, "error": "Invalid destination coordinates"} + Params().put("NavDestination", {"latitude": latitude, "longitude": longitude}, block=True) + return {"success": 1} + + +@dispatcher.add_method +def clearNavDestination() -> dict[str, int]: + Params().remove("NavDestination") + return {"success": 1} + + @dispatcher.add_method def setRouteViewed(route: str) -> dict[str, int | str]: # maintain a list of the last 10 routes viewed in connect diff --git a/openpilot/system/athena/tests/test_athenad.py b/openpilot/system/athena/tests/test_athenad.py index a89c8800f9..1f1a498ed6 100644 --- a/openpilot/system/athena/tests/test_athenad.py +++ b/openpilot/system/athena/tests/test_athenad.py @@ -112,6 +112,21 @@ class TestAthenadMethods(OpenpilotTestCase): # *** test cases *** + def test_nav_destination(self): + set_destination = dispatcher["setNavDestination"] + destination = {"latitude": 32.875134, "longitude": -117.209777} + assert set_destination(**destination) == {"success": 1} + assert self.params.get("NavDestination") == destination + + # out of range and non-finite coordinates are rejected without touching the destination + for latitude, longitude in [(91, 0), (0, -181), (float('nan'), 0), (0, float('inf'))]: + assert set_destination(latitude, longitude)["success"] == 0, (latitude, longitude) + assert self.params.get("NavDestination") == destination + + assert dispatcher["clearNavDestination"]() == {"success": 1} + assert dispatcher["clearNavDestination"]() == {"success": 1} + assert self.params.get("NavDestination") is None + def test_echo(self): assert dispatcher["echo"]("bob") == "bob" diff --git a/openpilot/system/manager/process_config.py b/openpilot/system/manager/process_config.py index b8a1e4a12e..82da166cd9 100644 --- a/openpilot/system/manager/process_config.py +++ b/openpilot/system/manager/process_config.py @@ -55,6 +55,9 @@ def always_run(started: bool, params: Params, CP: car.CarParams) -> bool: def only_onroad(started: bool, params: Params, CP: car.CarParams) -> bool: return started +def navigation(started: bool, params: Params, CP: car.CarParams) -> bool: + return started and params.get_bool("NavigationEnabled") + def only_offroad(started: bool, params: Params, CP: car.CarParams) -> bool: return not started @@ -83,6 +86,8 @@ procs = [ PythonProcess("timed", "openpilot.system.timed", always_run, enabled=not PC), PythonProcess("modeld", "openpilot.selfdrive.modeld.modeld", only_onroad), + PythonProcess("navd", "openpilot.selfdrive.navigation.navd", navigation), + PythonProcess("mapsd", "openpilot.selfdrive.navigation.mapsd", navigation), PythonProcess("dmonitoringmodeld", "openpilot.selfdrive.modeld.dmonitoringmodeld", driverview, enabled=(WEBCAM or not PC)), PythonProcess("sensord", "openpilot.system.sensord.sensord", only_onroad, enabled=not PC), From 2713630e7950a61dc072467b5394a1043e34547f Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Mon, 12 Oct 2026 20:49:45 -0700 Subject: [PATCH 222/270] Remove car mass (#39072) --- opendbc_repo | 2 +- openpilot/selfdrive/locationd/models/car_kf.py | 9 +++++---- openpilot/selfdrive/locationd/paramsd.py | 7 +++---- openpilot/selfdrive/test/process_replay/migration.py | 7 +++++++ 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index 229dc7062d..b3bd79a659 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 229dc7062d8986b4f954c7c97875b4ffd0044d12 +Subproject commit b3bd79a659dae961af169a3baac723961f720133 diff --git a/openpilot/selfdrive/locationd/models/car_kf.py b/openpilot/selfdrive/locationd/models/car_kf.py index 27cc4ef9c9..9bcec919dd 100755 --- a/openpilot/selfdrive/locationd/models/car_kf.py +++ b/openpilot/selfdrive/locationd/models/car_kf.py @@ -80,7 +80,6 @@ class CarKalman(KalmanFilter): } global_vars = [ - 'mass', 'rotational_inertia', 'center_to_front', 'center_to_rear', @@ -99,7 +98,10 @@ class CarKalman(KalmanFilter): # globals global_vars = [sp.Symbol(name) for name in CarKalman.global_vars] - m, j, aF, aR, cF_orig, cR_orig = global_vars + j, aF, aR, cF_orig, cR_orig = global_vars + # Tire stiffness and rotational inertia are already divided by vehicle mass, which cancels out of the model. + # Use unit mass to preserve the standard bicycle-model equations below. + m = 1 # make functions and jacobians with sympy # state variables @@ -166,8 +168,7 @@ class CarKalman(KalmanFilter): self.filter = EKF_sym_pyx(generated_dir, CarKalman.name, CarKalman.Q, CarKalman.initial_x, CarKalman.P_initial, dim_state, dim_state_err, global_vars=CarKalman.global_vars, logger=cloudlog) - def set_globals(self, mass, rotational_inertia, center_to_front, center_to_rear, stiffness_front, stiffness_rear): - self.filter.set_global("mass", mass) + def set_globals(self, rotational_inertia, center_to_front, center_to_rear, stiffness_front, stiffness_rear): self.filter.set_global("rotational_inertia", rotational_inertia) self.filter.set_global("center_to_front", center_to_front) self.filter.set_global("center_to_rear", center_to_rear) diff --git a/openpilot/selfdrive/locationd/paramsd.py b/openpilot/selfdrive/locationd/paramsd.py index 760176bc5c..18215c67b9 100755 --- a/openpilot/selfdrive/locationd/paramsd.py +++ b/openpilot/selfdrive/locationd/paramsd.py @@ -36,12 +36,11 @@ class VehicleParamsLearner: self.P_initial = P_initial if P_initial is not None else CarKalman.P_initial self.kf.set_globals( - mass=CP.mass, - rotational_inertia=CP.rotationalInertia, + rotational_inertia=CP.rotationalInertiaPerMass, center_to_front=CP.centerToFront, center_to_rear=CP.wheelbase - CP.centerToFront, - stiffness_front=CP.tireStiffnessFront, - stiffness_rear=CP.tireStiffnessRear + stiffness_front=CP.tireStiffnessFrontPerMass, + stiffness_rear=CP.tireStiffnessRearPerMass ) self.min_sr, self.max_sr = 0.5 * CP.steerRatio, 2.0 * CP.steerRatio diff --git a/openpilot/selfdrive/test/process_replay/migration.py b/openpilot/selfdrive/test/process_replay/migration.py index bd6d2ad546..a77d3b7044 100644 --- a/openpilot/selfdrive/test/process_replay/migration.py +++ b/openpilot/selfdrive/test/process_replay/migration.py @@ -423,6 +423,13 @@ def migrate_carParams(msgs): ops = [] for index, msg in msgs: CP = msg.as_builder() + # Normalize by mass after https://github.com/commaai/opendbc/pull/3858 + mass = CP.carParams.deprecated.mass + if mass > 0: + CP.carParams.rotationalInertiaPerMass /= mass + CP.carParams.tireStiffnessFrontPerMass /= mass + CP.carParams.tireStiffnessRearPerMass /= mass + CP.carParams.deprecated.mass = 0 CP.carParams.carFingerprint = MIGRATION.get(CP.carParams.carFingerprint, CP.carParams.carFingerprint) for car_fw in CP.carParams.carFw: car_fw.brand = CP.carParams.brand From 42ccc5cebd83cd2efd2f7d009fe923141b0cf39a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Tue, 13 Oct 2026 09:55:11 -0700 Subject: [PATCH 223/270] Remove experimental mode initial cruise speed override (#39077) --- openpilot/selfdrive/car/cruise.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpilot/selfdrive/car/cruise.py b/openpilot/selfdrive/car/cruise.py index 5e2f9db306..fd6ccf9518 100644 --- a/openpilot/selfdrive/car/cruise.py +++ b/openpilot/selfdrive/car/cruise.py @@ -12,7 +12,6 @@ V_CRUISE_MIN = 8 V_CRUISE_MAX = 145 V_CRUISE_UNSET = 255 V_CRUISE_INITIAL = 40 -V_CRUISE_INITIAL_EXPERIMENTAL_MODE = 105 IMPERIAL_INCREMENT = round(CV.MPH_TO_KPH, 1) # round here to avoid rounding errors incrementing set speed ButtonEvent = car.CarState.ButtonEvent @@ -128,11 +127,9 @@ class VCruiseHelper: if self.CP.pcmCruise: return - initial = V_CRUISE_INITIAL_EXPERIMENTAL_MODE if experimental_mode else V_CRUISE_INITIAL - if any(b.type in (ButtonType.accelCruise, ButtonType.resumeCruise) for b in CS.buttonEvents) and self.v_cruise_initialized: self.v_cruise_kph = self.v_cruise_kph_last else: - self.v_cruise_kph = int(round(np.clip(CS.vEgo * CV.MS_TO_KPH, initial, V_CRUISE_MAX))) + self.v_cruise_kph = int(round(np.clip(CS.vEgo * CV.MS_TO_KPH, V_CRUISE_INITIAL, V_CRUISE_MAX))) self.v_cruise_cluster_kph = self.v_cruise_kph From afcbb171f69d55fdcb0a8befa65b349f6c707860 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Tue, 13 Oct 2026 11:25:55 -0700 Subject: [PATCH 224/270] chestnut: flash before going onroad (#39078) --- openpilot/system/hardware/hardwared.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/openpilot/system/hardware/hardwared.py b/openpilot/system/hardware/hardwared.py index e7de070e8e..e012cf8933 100755 --- a/openpilot/system/hardware/hardwared.py +++ b/openpilot/system/hardware/hardwared.py @@ -41,9 +41,10 @@ PANDA_STATES_TIMEOUT = round(1000 / SERVICE_LIST['pandaStates'].frequency * 1.5) ONROAD_CYCLE_TIME = 1 # seconds to wait offroad after requesting an onroad cycle class Chestnut: - # flash offroad, modeld ignores chestnut until the product string matches + # flash before going onroad, modeld ignores chestnut until the product string matches MAX_ATTEMPTS = 3 RETRY_INTERVAL = 20. + STARTUP_TIMEOUT = 30. def __init__(self): self.thread: threading.Thread | None = None @@ -52,6 +53,10 @@ class Chestnut: self.flashed = False self.mismatch = False + @property + def flashing(self) -> bool: + return self.thread is not None and self.thread.is_alive() and time.monotonic() - self.last_attempt < self.STARTUP_TIMEOUT + @property def failed(self) -> bool: return self.mismatch and self.attempts >= self.MAX_ATTEMPTS and self.thread is not None and not self.thread.is_alive() and not self.flashed @@ -73,7 +78,7 @@ class Chestnut: return if self.thread is not None and self.thread.is_alive(): return - if time.monotonic() - self.last_attempt < self.RETRY_INTERVAL: + if self.attempts > 0 and time.monotonic() - self.last_attempt < self.RETRY_INTERVAL: return self.attempts += 1 @@ -222,7 +227,7 @@ def hardware_thread(end_event, hw_queue) -> None: network_strength=NetworkStrength.unknown, network_stats={'wwanTx': -1, 'wwanRx': -1}, modem_temps=[], - usb_state=[], + usb_state=get_usb_state(), ) all_temp_filter = FirstOrderFilter(0., TEMP_TAU, DT_HW, initialized=False) @@ -359,6 +364,8 @@ def hardware_thread(end_event, hw_queue) -> None: # must be at an engageable thermal band to go onroad startup_conditions["device_temp_engageable"] = thermal_status < ThermalStatus.overheated + startup_conditions["not_flashing_chestnut"] = not chestnut.flashing + # ensure device is fully booted startup_conditions["device_booted"] = startup_conditions.get("device_booted", False) or HARDWARE.booted() From 4c535577640a1dc8d91b017584de2c9b010f491b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Tue, 13 Oct 2026 11:45:14 -0700 Subject: [PATCH 225/270] pigeond: overwrite cached AssistNow time with host UTC (#39071) * pigeond: fix stale AssistNow time assistance * pigeond: save GNSS state after stopping receiver * pigeond: preserve fractional UTC assistance * Revert "pigeond: save GNSS state after stopping receiver" This reverts commit 5bf5ea84d8a995f7561a58bdf6d071cc78759703. * pigeond: send current time after AssistNow assistance * pigeond: simplify AssistNow initialization filter * pigeond: send host time only with AssistNow * pigeond: check host time validity inside send_time * Update pigeond.py --------- Co-authored-by: haraschax <6804392+haraschax@users.noreply.github.com> --- openpilot/system/ubloxd/pigeond.py | 53 ++++++++++++++++-------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/openpilot/system/ubloxd/pigeond.py b/openpilot/system/ubloxd/pigeond.py index 08ed568d43..816f3acb86 100755 --- a/openpilot/system/ubloxd/pigeond.py +++ b/openpilot/system/ubloxd/pigeond.py @@ -67,7 +67,9 @@ def get_assistnow_messages() -> list[bytes]: while len(dat) > 0: assert dat[:2] == b"\xB5\x62" msg_len = 6 + (dat[5] << 8 | dat[4]) + 2 - msgs.append(dat[:msg_len]) + # Skip MGA-INI messages; time from proxyserver is a stale cache + if dat[2:4] != b"\x13\x40": + msgs.append(dat[:msg_len]) dat = dat[msg_len:] return msgs @@ -146,6 +148,31 @@ class TTYPigeon: return True return False +def send_time(pigeon: TTYPigeon) -> None: + if not system_time_valid(): + return + + cloudlog.warning("Sending current time to ublox") + + # UBX-MGA-INI-TIME_UTC + t_now = datetime.now(UTC).replace(tzinfo=None) + msg = add_ubx_checksum(b"\xB5\x62\x13\x40\x18\x00" + struct.pack(" None: # store almanac in flash pigeon.send(b"\xB5\x62\x09\x14\x04\x00\x00\x00\x00\x00\x21\xEC") @@ -217,29 +244,6 @@ def init_pigeon(pigeon: TTYPigeon) -> bool: else: cloudlog.error(f"failed to restore almanac backup, status: {restore_status}") - # sending time to ublox - if system_time_valid(): - t_now = datetime.now(UTC).replace(tzinfo=None) - cloudlog.warning("Sending current time to ublox") - - # UBX-MGA-INI-TIME_UTC - msg = add_ubx_checksum(b"\xB5\x62\x13\x40\x18\x00" + struct.pack(" Date: Tue, 13 Oct 2026 21:55:22 +0300 Subject: [PATCH 226/270] remove unused code (#39080) --- openpilot/common/gpio.py | 10 ----- openpilot/common/utils.py | 15 ------- .../selfdrive/controls/lib/drive_helpers.py | 1 - openpilot/selfdrive/test/helpers.py | 40 ------------------- openpilot/tools/lib/auth_config.py | 7 ---- 5 files changed, 73 deletions(-) diff --git a/openpilot/common/gpio.py b/openpilot/common/gpio.py index 8f025a2daf..5f4295e7bd 100644 --- a/openpilot/common/gpio.py +++ b/openpilot/common/gpio.py @@ -27,16 +27,6 @@ def gpio_read(pin: int) -> bool | None: return val -def gpio_export(pin: int) -> None: - if os.path.isdir(f"/sys/class/gpio/gpio{pin}"): - return - - try: - with open("/sys/class/gpio/export", 'w') as f: - f.write(str(pin)) - except Exception: - print(f"Failed to export gpio {pin}") - @cache def get_irq_action(irq: int) -> list[str]: try: diff --git a/openpilot/common/utils.py b/openpilot/common/utils.py index a73d2b8226..28b3782a4c 100644 --- a/openpilot/common/utils.py +++ b/openpilot/common/utils.py @@ -5,7 +5,6 @@ import contextlib import subprocess import time import functools -from subprocess import Popen, PIPE, TimeoutExpired import zstandard as zstd LOG_COMPRESSION_LEVEL = 10 # little benefit up to level 15. level ~17 is a small step change @@ -157,20 +156,6 @@ def run_cmd_default(cmd: list[str], default: str = "", cwd=None, env=None) -> st return default -@contextlib.contextmanager -def managed_proc(cmd: list[str], env: dict[str, str]): - proc = Popen(cmd, env=env, stdout=PIPE, stderr=PIPE) - try: - yield proc - finally: - if proc.poll() is None: - proc.terminate() - try: - proc.wait(timeout=5) - except TimeoutExpired: - proc.kill() - - def tabulate(tabular_data, headers=(), tablefmt="simple", floatfmt="g", stralign="left", numalign=None): rows = [list(row) for row in tabular_data] diff --git a/openpilot/selfdrive/controls/lib/drive_helpers.py b/openpilot/selfdrive/controls/lib/drive_helpers.py index 35ca45e56e..c350cff632 100644 --- a/openpilot/selfdrive/controls/lib/drive_helpers.py +++ b/openpilot/selfdrive/controls/lib/drive_helpers.py @@ -4,7 +4,6 @@ from openpilot.common.realtime import DT_CTRL, DT_MDL MIN_SPEED = 1.0 CONTROL_N = 17 -CAR_ROTATION_RADIUS = 0.0 # This is a turn radius smaller than most cars can achieve MAX_CURVATURE = 0.2 MIN_STABLE_DELAY = 0.3 diff --git a/openpilot/selfdrive/test/helpers.py b/openpilot/selfdrive/test/helpers.py index 71cdcee5e7..aa949edf70 100644 --- a/openpilot/selfdrive/test/helpers.py +++ b/openpilot/selfdrive/test/helpers.py @@ -105,17 +105,6 @@ def with_processes(processes, init_time=0, ignore_stopped=None): return wrapper -def noop(*args, **kwargs): - pass - - -def read_segment_list(segment_list_path): - with open(segment_list_path) as f: - seg_list = f.read().splitlines() - - return [(platform[2:], segment) for platform, segment in zip(seg_list[::2], seg_list[1::2], strict=True)] - - @contextlib.contextmanager def http_server_context(handler, setup=None): host = '127.0.0.1' @@ -133,32 +122,3 @@ def http_server_context(handler, setup=None): server.shutdown() server.server_close() t.join() - - -def with_http_server(func, handler=http.server.BaseHTTPRequestHandler, setup=None): - @wraps(func) - def inner(*args, **kwargs): - with http_server_context(handler, setup) as (host, port): - return func(*args, f"http://{host}:{port}", **kwargs) - return inner - - -def DirectoryHttpServer(directory) -> type[http.server.SimpleHTTPRequestHandler]: - # creates an http server that serves files from directory - class Handler(http.server.SimpleHTTPRequestHandler): - API_NO_RESPONSE = False - API_BAD_RESPONSE = False - - def do_GET(self): - if self.API_NO_RESPONSE: - return - - if self.API_BAD_RESPONSE: - self.send_response(500, "") - return - super().do_GET() - - def __init__(self, *args, **kwargs): - super().__init__(*args, directory=str(directory), **kwargs) - - return Handler diff --git a/openpilot/tools/lib/auth_config.py b/openpilot/tools/lib/auth_config.py index 954225d4d3..cc29b3ba1a 100644 --- a/openpilot/tools/lib/auth_config.py +++ b/openpilot/tools/lib/auth_config.py @@ -21,10 +21,3 @@ def set_token(token): os.makedirs(Paths.config_root(), exist_ok=True) with atomic_write(os.path.join(Paths.config_root(), 'auth.json'), overwrite=True) as f: json.dump({'access_token': token}, f) - - -def clear_token(): - try: - os.unlink(os.path.join(Paths.config_root(), 'auth.json')) - except FileNotFoundError: - pass From 675d38213e1a04bc62b2c1c19ce32a1bfd58c822 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Tue, 13 Oct 2026 16:29:53 -0700 Subject: [PATCH 227/270] ssh.py: try local route first (#39082) --- tools/scripts/ssh.py | 120 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 103 insertions(+), 17 deletions(-) diff --git a/tools/scripts/ssh.py b/tools/scripts/ssh.py index 2d8ff46604..35c396815a 100755 --- a/tools/scripts/ssh.py +++ b/tools/scripts/ssh.py @@ -2,16 +2,82 @@ import os import sys import argparse +import queue import re +import socket +import subprocess +import threading +import time from openpilot.common.basedir import BASEDIR from openpilot.tools.lib.auth_config import get_token from openpilot.tools.lib.api import CommaApi +LOCAL_TIMEOUT = 1.5 +ATHENA_TIMEOUT = 8.0 + + +def read_banner(f) -> None: + banner = f.readline() + if not banner.startswith(b"SSH-"): + raise ConnectionError("no ssh banner") + + +def probe_local(host: str) -> None: + with socket.create_connection((host, 22), timeout=LOCAL_TIMEOUT) as s, s.makefile("rb") as f: + read_banner(f) + + +def probe_athena(proxy_cmd: list[str], procs: list[subprocess.Popen]) -> None: + p = subprocess.Popen(proxy_cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + procs.append(p) + try: + read_banner(p.stdout) + finally: + p.kill() + + +def run_probe(name: str, fn, results: queue.Queue) -> None: + t = time.monotonic() + try: + fn() + results.put((name, (time.monotonic() - t) * 1000, None)) + except socket.gaierror: + results.put((name, None, "not found")) + except Exception as e: + results.put((name, None, type(e).__name__ if not str(e) else str(e))) + + +def probe_routes(routes: dict, athena_proxy: list[str]) -> tuple[str | None, dict]: + # probe all routes in parallel, prefer local since it's direct + results: queue.Queue = queue.Queue() + procs: list[subprocess.Popen] = [] + fns = {"local": lambda: probe_local(routes["local"]), "athena": lambda: probe_athena(athena_proxy, procs)} + for name in routes: + threading.Thread(target=run_probe, args=(name, fns[name], results), daemon=True).start() + + status: dict = {} + deadline = time.monotonic() + ATHENA_TIMEOUT + choice = None + while len(status) < len(routes) and choice is None: + try: + name, ms, err = results.get(timeout=max(0, deadline - time.monotonic())) + except queue.Empty: + break + status[name] = (ms, err) + if err is None and (name == "local" or status.get("local", (None, "x"))[1] is not None or "local" not in routes): + choice = name + + for p in procs: + p.kill() + return choice, status + + if __name__ == "__main__": - parser = argparse.ArgumentParser(description="A helper for connecting to devices over the comma prime SSH proxy.\ - Adding your SSH key to your SSH config is recommended for more convenient use; see https://docs.comma.ai/how-to/connect-to-comma/.") + parser = argparse.ArgumentParser(description="A helper for connecting to devices, either directly over the local network or through the \ + comma prime SSH proxy. Adding your SSH key to your SSH config is recommended for more convenient use; see https://docs.comma.ai/how-to/connect-to-comma/.") parser.add_argument("device", help="device name or dongle id") + parser.add_argument("--route", help="connection route", choices=["auto", "local", "athena"], default="auto") parser.add_argument("--host", help="ssh jump server host", default="ssh.comma.ai") parser.add_argument("--port", help="ssh jump server port", default=22, type=int) parser.add_argument("--key", help="ssh key", default=os.path.join(BASEDIR, "openpilot/common/hardware/comma/id_rsa")) @@ -19,11 +85,11 @@ if __name__ == "__main__": args = parser.parse_args() r = CommaApi(get_token()).get("v1/me/devices") - devices = {x['dongle_id']: x['alias'] for x in r} + devices = {x['dongle_id']: x for x in r} if not re.match("[0-9a-zA-Z]{16}", args.device): user_input = args.device.replace(" ", "").lower() - matches = { k: v for k, v in devices.items() if isinstance(v, str) and user_input in v.replace(" ", "").lower() } + matches = { k: v['alias'] for k, v in devices.items() if isinstance(v['alias'], str) and user_input in v['alias'].replace(" ", "").lower() } if len(matches) == 1: dongle_id = list(matches.keys())[0] else: @@ -36,22 +102,42 @@ if __name__ == "__main__": else: dongle_id = args.device - name = dongle_id - if dongle_id in devices: - name = f"{devices[dongle_id]} ({dongle_id})" - print(f"connecting to {name} through {args.host}:{args.port} ...") + device = devices.get(dongle_id, {}) + name = f"{device['alias']} ({dongle_id})" if device else dongle_id - command = [ - "ssh", - "-i", args.key, - "-o", f"ProxyCommand=ssh -i {args.key} -W %h:%p -p %p %h@{args.host}", - "-p", str(args.port), - ] + athena_host = f"comma-{dongle_id}" + athena_proxy = ["ssh", "-i", args.key, "-o", "BatchMode=yes", "-W", f"{athena_host}:{args.port}", "-p", str(args.port), f"{athena_host}@{args.host}"] + routes = {} + if device.get('serial') and args.route in ("auto", "local"): + routes["local"] = f"comma-{device['serial']}" + if args.route in ("auto", "athena"): + routes["athena"] = args.host + + if not routes: + print(f"no serial known for {name}, can't use local route", file=sys.stderr) + exit(1) + + print(f"connecting to {name}") + if len(routes) > 1: + route, status = probe_routes(routes, athena_proxy) + for r_name, r_host in routes.items(): + ms, err = status.get(r_name, (None, "not needed" if route else "timeout")) + result = f"{ms:.0f} ms" if err is None else err + print(f" {'*' if r_name == route else ' '} {r_name:<7}{r_host:<24}{result}") + if route is None: + print("no route to device", file=sys.stderr) + exit(1) + else: + route = next(iter(routes)) + print(f" * {route:<7}{routes[route]:<24}{'forced' if args.route != 'auto' else 'only route (serial unknown)'}") + + command = ["ssh", "-i", args.key] + if route == "athena": + command += ["-o", f"ProxyCommand=ssh -i {args.key} -W %h:%p -p %p %h@{args.host}", "-p", str(args.port)] if args.debug: command += ["-v"] - command += [ - f"comma@comma-{dongle_id}", - ] + command += [f"comma@{athena_host if route == 'athena' else routes['local']}"] if args.debug: print(" ".join([f"'{c}'" if " " in c else c for c in command])) + sys.stdout.flush() os.execvp(command[0], command) From 1623441a9f080138570e1d37c9a261ee3511357e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Tue, 13 Oct 2026 16:56:59 -0700 Subject: [PATCH 228/270] pigeond: aid startup with last GPS position (#39079) * pigeond: aid startup with last GPS position * pigeond: aid position after receiver setup * pigeond: fixed 100km position accuracy, skip stale positions * store LastGPSPosition as capnp bytes * ubloxd: save LastGPSPosition * pigeond: send time and position once system time is valid * pigeond: use datetime for saved position age * pigeond: remove position accuracy comment * pigeond: send position assistance during initialization * Update ubloxd.py --------- Co-authored-by: haraschax <6804392+haraschax@users.noreply.github.com> --- openpilot/common/params_keys.h | 2 +- openpilot/system/ubloxd/pigeond.py | 28 ++++++++++++++++++++++++++++ openpilot/system/ubloxd/ubloxd.py | 8 ++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 15fc62dfca..cedf84f26b 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -68,7 +68,7 @@ inline static std::unordered_map keys = { {"JoystickDebugMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"LanguageSetting", {PERSISTENT, STRING, "en"}}, {"LastAthenaPingTime", {CLEAR_ON_MANAGER_START, INT}}, - {"LastGPSPosition", {PERSISTENT, STRING}}, + {"LastGPSPosition", {PERSISTENT, BYTES}}, {"LastManagerExitReason", {CLEAR_ON_MANAGER_START, STRING}}, {"LastOffroadStatusPacket", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, JSON}}, {"LastAgnosPowerMonitorShutdown", {CLEAR_ON_MANAGER_START, STRING}}, diff --git a/openpilot/system/ubloxd/pigeond.py b/openpilot/system/ubloxd/pigeond.py index 816f3acb86..df4632b118 100755 --- a/openpilot/system/ubloxd/pigeond.py +++ b/openpilot/system/ubloxd/pigeond.py @@ -194,6 +194,27 @@ def init_baudrate(pigeon: TTYPigeon): pigeon.set_baud(460800) +def send_position(pigeon: TTYPigeon) -> None: + if not (position := Params().get("LastGPSPosition")): + return + + with log.Event.from_bytes(position) as evt: + gps = evt.gpsLocationExternal + if datetime.now(UTC).timestamp() - gps.unixTimestampMillis / 1000 > 7 * 24 * 3600: + return + cloudlog.warning("Sending last position to ublox") + + # UBX-MGA-INI-POS_LLH + msg = add_ubx_checksum(b"\xB5\x62\x13\x40\x14\x00" + struct.pack(" bool: # try initializing a few times for _ in range(10): @@ -275,6 +296,13 @@ def init(pigeon: TTYPigeon) -> None: init_baudrate(pigeon) init_pigeon(pigeon) + if system_time_valid(): + try: + send_time(pigeon) + send_position(pigeon) + except Exception: + cloudlog.exception("failed to send time and position to ublox") + def run_receiving(duration: int = 0): pm = messaging.PubMaster(['ubloxRaw']) diff --git a/openpilot/system/ubloxd/ubloxd.py b/openpilot/system/ubloxd/ubloxd.py index 3b32f70657..3365e9c693 100755 --- a/openpilot/system/ubloxd/ubloxd.py +++ b/openpilot/system/ubloxd/ubloxd.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import math +import time import capnp import calendar import numpy as np @@ -8,6 +9,7 @@ from dataclasses import dataclass from openpilot.cereal import log from openpilot.cereal import messaging +from openpilot.common.params import Params from openpilot.system.ubloxd.ubx import Ubx from openpilot.system.ubloxd.gps import Gps from openpilot.system.ubloxd.glonass import Glonass @@ -510,6 +512,8 @@ def main(): parser = UbloxMsgParser() pm = messaging.PubMaster(['ubloxGnss', 'gpsLocationExternal']) sock = messaging.sub_sock('ubloxRaw', timeout=100, conflate=False) + params = Params() + last_position_save = float('-inf') while True: msg = messaging.recv_one(sock) @@ -529,6 +533,10 @@ def main(): service, dat = res pm.send(service, dat) + if service == 'gpsLocationExternal' and dat.gpsLocationExternal.hasFix and time.monotonic() - last_position_save > 60: + params.put("LastGPSPosition", dat.to_bytes()) + last_position_save = time.monotonic() + if __name__ == '__main__': main() From 9bebe42f735e8859421ca3cfcb2f57377bb1f954 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:45:39 -0700 Subject: [PATCH 229/270] default models: fix big (#2046) * fix big * whoops --- .github/workflows/build-default-models.yaml | 36 +++++++++++++++++-- .../download-hf-model-chunks/action.yml | 5 +++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-default-models.yaml b/.github/workflows/build-default-models.yaml index 8ac48c4021..700c7bba8f 100644 --- a/.github/workflows/build-default-models.yaml +++ b/.github/workflows/build-default-models.yaml @@ -47,6 +47,7 @@ jobs: if [ "${{ inputs.target }}" = "big" ]; then NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL; print(DEFAULT_BIG_MODEL)") HF_DEFAULTS_PATH="models/defaults/big" + ONNX_PATH="openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl" elif [ "${{ inputs.target }}" = "dm" ]; then ONNX_PATH="openpilot/selfdrive/modeld/models/dmonitoring_model.onnx" HF_DEFAULTS_PATH="models/defaults/dm" @@ -235,6 +236,34 @@ jobs: print(f'Chunked into {len(targets)} files') " + - name: Compile big warp + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}" + + CHESTNUT_TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32" + COMPILER="${{ github.workspace }}/tinygrad_repo/examples/openpilot" + MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models" + MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')") + + for res in $(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')"); do + CAM_W=$(echo $res | cut -d x -f1) + CAM_H=$(echo $res | cut -d x -f2) + STRIDE_INFO=$(python3 -c " + import sys + from openpilot.system.camerad.cameras.nv12_info import get_nv12_info + s,y,u,sz = get_nv12_info(int(sys.argv[1]), int(sys.argv[2])) + print(f'{s},{y},{u},{sz}') + " $CAM_W $CAM_H) + WARP_PKL="${MODELS_DIR}/big_driving_warp_${res}_tinygrad.pkl" + env ${CHESTNUT_TG_FLAGS} python3 "${COMPILER}/compile_warp.py" \ + --frame ${CAM_W},${CAM_H},${STRIDE_INFO} \ + --warp-to ${MODEL_SIZE} \ + --layout yuv420 \ + --frames 2 \ + --output "${WARP_PKL}" + done + - name: Prepare output env: MODEL_NAME: ${{ needs.resolve.outputs.safe_model_name }} @@ -249,7 +278,7 @@ jobs: cp "$MODELS_DIR/${PKL_BASE}".chunk* "$OUTPUT_DIR/" cp "$MODELS_DIR/${PKL_BASE}.chunkmanifest" "$OUTPUT_DIR/" - PKL_LFS_SHA256=$(sha256sum "$MODELS_DIR/${PKL_BASE}" | cut -d' ' -f1) + PKL_LFS_SHA256=$(git cat-file blob "$(git ls-files -s "${{ github.workspace }}/${{ env.BIG_PKL }}" | awk '{print $2}')" | grep '^oid sha256:' | cut -d: -f2) python3 "${{ github.workspace }}/release/ci/model_generator.py" \ --model-dir "$MODELS_DIR" \ @@ -258,6 +287,8 @@ jobs: --upstream-branch "${{ needs.resolve.outputs.onnx_ref }}" \ --onnx-sha256 "$PKL_LFS_SHA256" + cp "$MODELS_DIR"/big_driving_warp_* "$OUTPUT_DIR/" || true + echo "model-${MODEL_NAME}-${{ github.run_number }}" > "$OUTPUT_DIR/artifact_name.txt" - name: Upload big model artifact @@ -335,6 +366,8 @@ jobs: --hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \ --artifact-name "$ARTIFACT_NAME" \ --model-dir output \ + --onnx-path "${{ needs.resolve.outputs.onnx_path }}" \ + --onnx-ref "${{ needs.resolve.outputs.onnx_ref }}" \ --model-name "${{ needs.resolve.outputs.model_name }}" \ --tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \ --run-number "${{ github.run_number }}" @@ -514,4 +547,3 @@ jobs: run: | source ${UV_PROJECT_ENVIRONMENT}/bin/activate PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable - diff --git a/.github/workflows/download-hf-model-chunks/action.yml b/.github/workflows/download-hf-model-chunks/action.yml index 83c0dcd133..2eb87cfbb7 100644 --- a/.github/workflows/download-hf-model-chunks/action.yml +++ b/.github/workflows/download-hf-model-chunks/action.yml @@ -71,6 +71,11 @@ runs: ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${warp}', safe=':/'))") printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${warp}" >> "$DOWNLOAD_LIST" done + elif [ "$CANONICAL" = "big_driving_tinygrad.pkl" ]; then + for warp in big_driving_warp_1928x1208_tinygrad.pkl big_driving_warp_1344x760_tinygrad.pkl; do + ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${warp}', safe=':/'))") + printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${warp}" >> "$DOWNLOAD_LIST" + done fi } From 50319fa702f95ee8a677b466bc66c55a9a680598 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Wed, 14 Oct 2026 08:19:41 -0700 Subject: [PATCH 230/270] simple SPI (#39076) --- openpilot/selfdrive/pandad/panda_comms.h | 15 +- openpilot/selfdrive/pandad/spi.cc | 406 +++++++++--------- .../selfdrive/pandad/tests/test_pandad_spi.py | 12 +- panda | 2 +- 4 files changed, 209 insertions(+), 226 deletions(-) diff --git a/openpilot/selfdrive/pandad/panda_comms.h b/openpilot/selfdrive/pandad/panda_comms.h index cdfb5019b6..77c085fdc2 100644 --- a/openpilot/selfdrive/pandad/panda_comms.h +++ b/openpilot/selfdrive/pandad/panda_comms.h @@ -8,7 +8,7 @@ #define TIMEOUT 0 -#define SPI_BUF_SIZE 2048 +#define SPI_BUF_SIZE 4096 class PandaSpiHandle { @@ -35,15 +35,18 @@ private: inline static std::recursive_mutex hw_lock; struct __attribute__((packed)) spi_header { - uint8_t sync; uint8_t endpoint; + uint32_t id; uint16_t tx_len; - uint16_t max_rx_len; + uint16_t rx_len; + }; + struct __attribute__((packed)) spi_resp_header { + uint8_t status; + uint32_t id; + uint16_t len; }; - int wait_for_ack(uint8_t ack, uint8_t tx, unsigned int timeout, unsigned int length); - int bulk_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t rx_len, unsigned int timeout); - int spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, unsigned int timeout); + int spi_transfer(uint8_t endpoint, uint32_t id, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, double deadline); int spi_transfer_retry(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, unsigned int timeout); int lltransfer(struct spi_ioc_transfer &t); diff --git a/openpilot/selfdrive/pandad/spi.cc b/openpilot/selfdrive/pandad/spi.cc index f54c26e506..8636861cb4 100644 --- a/openpilot/selfdrive/pandad/spi.cc +++ b/openpilot/selfdrive/pandad/spi.cc @@ -2,10 +2,12 @@ #include #include +#include #include #include #include #include +#include #include #include "common/util.h" @@ -15,19 +17,25 @@ #include "selfdrive/pandad/panda_comms.h" -#define SPI_SYNC 0x5AU -#define SPI_HACK 0x79U -#define SPI_DACK 0x85U -#define SPI_NACK 0x1FU -#define SPI_CHECKSUM_START 0xABU +// protocol, see panda/board/drivers/spi.h +#define SPI_SLAVE_READY 0x3CU +#define SPI_ACK 0xC3U +#define SPI_NACK 0x5AU +#define SPI_POLL 0xFFU // filler while reading the response, never a valid request + +#define SPI_CRC_SIZE 4U +#define SPI_XFER_SIZE (SPI_BUF_SIZE - 0x40) // bulk chunk size, leaves room for the protocol overhead +#define SPI_FIRST_READ_SIZE (1024 - sizeof(spi_resp_header) - SPI_CRC_SIZE) // reads are usually small, start small enum SpiError { NACK = -2, ACK_TIMEOUT = -3, + SILENT = -4, }; -const unsigned int SPI_ACK_TIMEOUT = 500; // milliseconds +const unsigned int SPI_MIN_TIMEOUT = 100; // milliseconds +const double SPI_SILENT_TIMEOUT = 1000; // milliseconds, longer than the slowest request takes on the panda const std::string SPI_DEVICE = "/dev/spidev0.0"; class LockEx { @@ -50,8 +58,8 @@ private: #define SPILOG(fn, fmt, ...) do { \ fn(fmt, ## __VA_ARGS__); \ fn(" %d / 0x%x / %d / %d / tx: %s", \ - xfer_count, header.endpoint, header.tx_len, header.max_rx_len, \ - util::hexdump(tx_buf, std::min((int)header.tx_len, 8)).c_str()); \ + xfer_count, header.endpoint, header.tx_len, header.rx_len, \ + util::hexdump(tx_buf + sizeof(header), std::min((int)header.tx_len, 8)).c_str()); \ } while (0) PandaSpiHandle::PandaSpiHandle(std::string serial) { @@ -82,7 +90,7 @@ PandaSpiHandle::PandaSpiHandle(std::string serial) { util::safe_ioctl(spi_fd, SPI_IOC_WR_BITS_PER_WORD, &spi_bits_per_word, "failed setting SPI bits per word"); // get hw UID/serial - ret = control_read(0xc3, 0, 0, uid, uid_len, 100); + ret = control_read(0xc3, 0, 0, uid, uid_len, 500); if (ret == uid_len) { std::stringstream stream; for (int i = 0; i < uid_len; i++) { @@ -140,40 +148,31 @@ int PandaSpiHandle::control_read(uint8_t request, uint16_t param1, uint16_t para } int PandaSpiHandle::bulk_write(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout) { - return bulk_transfer(endpoint, data, length, NULL, 0, timeout); -} -int PandaSpiHandle::bulk_read(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout) { - return bulk_transfer(endpoint, NULL, 0, data, length, timeout); -} - -int PandaSpiHandle::bulk_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t rx_len, unsigned int timeout) { - const int xfer_size = SPI_BUF_SIZE - 0x40; - - int ret = 0; - uint16_t length = (tx_data != NULL) ? tx_len : rx_len; - for (int i = 0; i < (int)std::ceil((float)length / xfer_size); i++) { - int d; - if (tx_data != NULL) { - int len = std::min(xfer_size, tx_len - (xfer_size * i)); - d = spi_transfer_retry(endpoint, tx_data + (xfer_size * i), len, NULL, 0, timeout); - } else { - uint16_t to_read = std::min(xfer_size, rx_len - ret); - d = spi_transfer_retry(endpoint, NULL, 0, rx_data + (xfer_size * i), to_read, timeout); - } - - if (d < 0) { - SPILOG(LOGE, "SPI: bulk transfer failed with %d", d); + for (int pos = 0; pos < length; pos += SPI_XFER_SIZE) { + int ret = spi_transfer_retry(endpoint, data + pos, std::min(SPI_XFER_SIZE, length - pos), NULL, 0, timeout); + if (ret < 0) { comms_healthy = false; - return d; + return ret; } + } + return length; +} - ret += d; - if ((rx_data != NULL) && d < xfer_size) { +int PandaSpiHandle::bulk_read(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout) { + int pos = 0; + while (pos < length) { + int to_read = std::min(pos == 0 ? (int)SPI_FIRST_READ_SIZE : SPI_XFER_SIZE, length - pos); + int ret = spi_transfer_retry(endpoint, NULL, 0, data + pos, to_read, timeout); + if (ret < 0) { + comms_healthy = false; + return ret; + } + pos += ret; + if (ret < to_read) { break; } } - - return ret; + return pos; } std::vector PandaSpiHandle::list() { @@ -186,223 +185,202 @@ std::vector PandaSpiHandle::list() { return {}; } -void add_checksum(uint8_t *data, int data_len) { - data[data_len] = SPI_CHECKSUM_START; - for (int i=0; i < data_len; i++) { - data[data_len] ^= data[i]; - } +// random, so it's never the same as another request the panda just handled, even from another client +static uint32_t random_id() { + static thread_local std::mt19937 rng{std::random_device{}()}; + return rng(); } -bool check_checksum(uint8_t *data, int data_len) { - uint8_t checksum = SPI_CHECKSUM_START; - for (uint16_t i = 0U; i < data_len; i++) { - checksum ^= data[i]; +// standard CRC-32, same as zlib's crc32() +static uint32_t crc32(const uint8_t *data, uint32_t len) { + static const auto table = [] { + std::array t{}; + for (uint32_t i = 0; i < 256; i++) { + uint32_t c = i; + for (int j = 0; j < 8; j++) { + c = (c & 1) ? (0xEDB88320U ^ (c >> 1)) : (c >> 1); + } + t[i] = c; + } + return t; + }(); + + uint32_t crc = 0xFFFFFFFFU; + for (uint32_t i = 0; i < len; i++) { + crc = table[(crc ^ data[i]) & 0xFFU] ^ (crc >> 8); } - return checksum == 0U; + return crc ^ 0xFFFFFFFFU; +} + +static void put_crc(uint8_t *data, uint32_t len) { + uint32_t crc = crc32(data, len); + memcpy(&data[len], &crc, sizeof(crc)); +} + +static bool check_crc(const uint8_t *data, uint32_t len) { + uint32_t crc; + memcpy(&crc, &data[len], sizeof(crc)); + return crc == crc32(data, len); } int PandaSpiHandle::spi_transfer_retry(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, unsigned int timeout) { + // timeout of 0 means forever + const double deadline = (timeout == 0) ? INFINITY : millis_since_boot() + std::max(timeout, SPI_MIN_TIMEOUT); + + // the id stays the same when we send the request again, so the panda never runs it twice + uint32_t id = random_id(); int ret; int nack_count = 0; - int timeout_count = 0; - bool timed_out = false; - double start_time = millis_since_boot(); - - do { - ret = spi_transfer(endpoint, tx_data, tx_len, rx_data, max_rx_len, timeout); - - if (ret < 0) { - timed_out = (timeout != 0) && (timeout_count > 5); - timeout_count += ret == SpiError::ACK_TIMEOUT; - - // give other threads a chance to run - std::this_thread::yield(); - - if (ret == SpiError::NACK) { - // prevent busy waiting while the panda is NACK'ing - // due to full TX buffers - nack_count += 1; - if (nack_count > 3) { - SPILOG(LOGD, "NACK sleep %d", nack_count); - usleep(std::clamp(nack_count*10, 200, 2000)); - } - } + while (true) { + ret = spi_transfer(endpoint, id, tx_data, tx_len, rx_data, max_rx_len, deadline); + if (ret >= 0 || !connected) { + break; } - } while (ret < 0 && connected && !timed_out); + + if (ret == SpiError::NACK) { + // the panda NACKs while its CAN TX buffers are full. that's not a comms + // failure, so keep trying regardless of the timeout, without busy waiting. + // a new id, since the panda would answer the same id with the same NACK + id = random_id(); + nack_count += 1; + if (nack_count > 3) { + usleep(std::clamp(nack_count*10, 200, 2000)); + } + } else if (ret == SpiError::SILENT) { + // the panda went quiet. let go of the bus for a bit, someone else might need it + LOGW("SPI: panda isn't responding"); + usleep(10000); + } else if (millis_since_boot() > deadline) { + break; + } + + // give other threads a chance to run + std::this_thread::yield(); + } if (ret < 0) { - SPILOG(LOGE, "transfer failed, after %d tries, %.2fms", timeout_count, millis_since_boot() - start_time); + SPILOG(LOGE, "SPI: transfer failed with %d", ret); } - return ret; } -int PandaSpiHandle::wait_for_ack(uint8_t ack, uint8_t tx, unsigned int timeout, unsigned int length) { - double start_millis = millis_since_boot(); - if (timeout == 0) { - timeout = SPI_ACK_TIMEOUT; - } - timeout = std::clamp(timeout, 20U, SPI_ACK_TIMEOUT); - - spi_ioc_transfer transfer = { - .tx_buf = (uint64_t)tx_buf, - .rx_buf = (uint64_t)rx_buf, - .len = length, - }; - memset(tx_buf, tx, length); - - while (true) { - int ret = lltransfer(transfer); - if (ret < 0) { - SPILOG(LOGE, "SPI: failed to send ACK request"); - return ret; - } - - if (rx_buf[0] == ack) { - break; - } else if (rx_buf[0] == SPI_NACK) { - SPILOG(LOGD, "SPI: got NACK, waiting for 0x%x", ack); - return SpiError::NACK; - } - - // handle timeout - if (millis_since_boot() - start_millis > timeout) { - SPILOG(LOGW, "SPI: timed out waiting for ACK, waiting for 0x%x", ack); - return SpiError::ACK_TIMEOUT; - } - } - - return 0; -} - int PandaSpiHandle::lltransfer(spi_ioc_transfer &t) { static const double err_prob = std::stod(util::getenv("SPI_ERR_PROB", "-1")); + auto chance = [](double p) { return (static_cast(rand()) / RAND_MAX) < p; }; + auto corrupt = [&](uint64_t buf) { + for (uint32_t i = 0; i < t.len; i++) { + if (chance(0.1)) ((uint8_t*)buf)[i] = (uint8_t)(rand() % 256); + } + }; - if (err_prob > 0) { - if ((static_cast(rand()) / RAND_MAX) < err_prob) { - printf("transfer len error\n"); - t.len = rand() % SPI_BUF_SIZE; - } - if ((static_cast(rand()) / RAND_MAX) < err_prob && t.tx_buf != (uint64_t)NULL) { - printf("corrupting TX\n"); - for (int i = 0; i < t.len; i++) { - if ((static_cast(rand()) / RAND_MAX) > 0.9) { - ((uint8_t*)t.tx_buf)[i] = (uint8_t)(rand() % 256); - } - } - } + if (err_prob > 0 && chance(err_prob)) { + printf("transfer len error\n"); + t.len = rand() % SPI_BUF_SIZE; + } + if (err_prob > 0 && chance(err_prob) && t.tx_buf == (uint64_t)tx_buf) { + printf("corrupting TX\n"); + corrupt(t.tx_buf); } int ret = util::safe_ioctl(spi_fd, SPI_IOC_MESSAGE(1), &t); - - if (err_prob > 0) { - if ((static_cast(rand()) / RAND_MAX) < err_prob && t.rx_buf != (uint64_t)NULL) { - printf("corrupting RX\n"); - for (int i = 0; i < t.len; i++) { - if ((static_cast(rand()) / RAND_MAX) > 0.9) { - ((uint8_t*)t.rx_buf)[i] = (uint8_t)(rand() % 256); - } - } - } + if (err_prob > 0 && chance(err_prob)) { + printf("ioctl error\n"); + ret = -1; } + if (err_prob > 0 && chance(err_prob)) { + printf("corrupting RX\n"); + corrupt(t.rx_buf); + } return ret; } -int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, unsigned int timeout) { - int ret; - uint16_t rx_data_len; +// One request and its response. Resends until the panda got the request and we got its response, +// or the deadline. Every transfer either sees all of what the panda queued from its first byte, or +// nothing it can mistake for a response. Returns the response length, or a negative SpiError +int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint32_t id, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, double deadline) { + static const auto poll = [] { + std::array p; + p.fill(SPI_POLL); + return p; + }(); + LockEx lock(spi_fd, hw_lock); - // needs to be less, since we need to have space for the checksum - assert(tx_len < SPI_BUF_SIZE); - assert(max_rx_len < SPI_BUF_SIZE); + // the response is always read in one transfer of rx_len + const uint32_t rx_len = sizeof(spi_resp_header) + max_rx_len + SPI_CRC_SIZE; + const uint32_t req_len = sizeof(header) + tx_len; + assert(req_len + SPI_CRC_SIZE <= SPI_BUF_SIZE); + assert(rx_len <= SPI_BUF_SIZE); xfer_count++; header = { - .sync = SPI_SYNC, .endpoint = endpoint, + .id = id, .tx_len = tx_len, - .max_rx_len = max_rx_len + .rx_len = (uint16_t)rx_len, }; - spi_ioc_transfer transfer = { - .tx_buf = (uint64_t)tx_buf, - .rx_buf = (uint64_t)rx_buf + auto xfer = [&](const uint8_t *tx, uint32_t len) { + spi_ioc_transfer t = {.tx_buf = (uint64_t)tx, .rx_buf = (uint64_t)rx_buf, .len = len}; + int ret = lltransfer(t); + if (ret < 0) { + SPILOG(LOGE, "SPI: transfer failed"); + } + return ret; }; - // Send header - memcpy(tx_buf, &header, sizeof(header)); - add_checksum(tx_buf, sizeof(header)); - transfer.len = sizeof(header) + 1; - ret = lltransfer(transfer); - if (ret < 0) { - SPILOG(LOGE, "SPI: failed to send header"); - goto fail; - } + // when the panda last showed it's there. while it's quiet, we let go of the bus + double alive = millis_since_boot(); + while (true) { + // send the request, until the panda was ready for it. it's rebuilt every time, + // since SPI_ERR_PROB corrupts tx_buf + memcpy(tx_buf, &header, sizeof(header)); + if (tx_data != NULL) { + memcpy(tx_buf + sizeof(header), tx_data, tx_len); + } + put_crc(tx_buf, req_len); + // a failed transfer is sent again like any other, keeping the bus, so nothing can run in between + if (xfer(tx_buf, req_len + SPI_CRC_SIZE) >= 0 && rx_buf[0] == SPI_SLAVE_READY) { + alive = millis_since_boot(); + // read until the response. if the panda is ready again, it never got the request + do { + if (xfer(poll.data(), rx_len) < 0) { + break; + } + if (rx_buf[0] == SPI_SLAVE_READY) { + alive = millis_since_boot(); + break; + } - // Wait for (N)ACK - ret = wait_for_ack(SPI_HACK, 0x11, timeout, 1); - if (ret < 0) { - goto fail; - } + // a response that got cut off or corrupted isn't one, keep reading + spi_resp_header resp; + memcpy(&resp, rx_buf, sizeof(resp)); + if ((resp.status == SPI_ACK || resp.status == SPI_NACK) && resp.len <= max_rx_len && check_crc(rx_buf, sizeof(resp) + resp.len)) { + // otherwise it's the response to an old request, and we send ours again + if (resp.id == header.id) { + if (resp.status == SPI_NACK) { + SPILOG(LOGD, "SPI: got NACK"); + return SpiError::NACK; + } + if (rx_data != NULL) { + memcpy(rx_data, rx_buf + sizeof(resp), resp.len); + } + return resp.len; + } + alive = millis_since_boot(); + break; + } + } while (millis_since_boot() < std::min(deadline, alive + SPI_SILENT_TIMEOUT)); + } - // Send data - if (tx_data != NULL) { - memcpy(tx_buf, tx_data, tx_len); - } - add_checksum(tx_buf, tx_len); - transfer.len = tx_len + 1; - ret = lltransfer(transfer); - if (ret < 0) { - SPILOG(LOGE, "SPI: failed to send data"); - goto fail; - } - - // Wait for (N)ACK - ret = wait_for_ack(SPI_DACK, 0x13, timeout, 3); - if (ret < 0) { - goto fail; - } - - // Read data - rx_data_len = *(uint16_t *)(rx_buf+1); - if (rx_data_len >= SPI_BUF_SIZE) { - SPILOG(LOGE, "SPI: RX data len larger than buf size %d", rx_data_len); - goto fail; - } - - transfer.len = rx_data_len + 1; - transfer.rx_buf = (uint64_t)(rx_buf + 2 + 1); - ret = lltransfer(transfer); - if (ret < 0) { - SPILOG(LOGE, "SPI: failed to read rx data"); - goto fail; - } - if (!check_checksum(rx_buf, rx_data_len + 4)) { - SPILOG(LOGE, "SPI: bad checksum"); - goto fail; - } - - if (rx_data != NULL) { - memcpy(rx_data, rx_buf + 3, rx_data_len); - } - - return rx_data_len; - -fail: - // ensure slave is in a consistent state - // and ready for the next transfer - int nack_cnt = 0; - while (nack_cnt < 3) { - if (wait_for_ack(SPI_NACK, 0x14, 1, SPI_BUF_SIZE/2) == 0) { - nack_cnt += 1; - } else { - nack_cnt = 0; + if (millis_since_boot() > deadline) { + SPILOG(LOGW, "SPI: timed out"); + return SpiError::ACK_TIMEOUT; + } + if (millis_since_boot() > alive + SPI_SILENT_TIMEOUT) { + return SpiError::SILENT; } } - - if (ret >= 0) ret = -1; - return ret; } diff --git a/openpilot/selfdrive/pandad/tests/test_pandad_spi.py b/openpilot/selfdrive/pandad/tests/test_pandad_spi.py index 8ca39445a2..2818654282 100755 --- a/openpilot/selfdrive/pandad/tests/test_pandad_spi.py +++ b/openpilot/selfdrive/pandad/tests/test_pandad_spi.py @@ -45,9 +45,10 @@ class TestBoarddSpi(OpenpilotTestCase): st = time.monotonic() ts = {s: [] for s in socks.keys()} - for _ in range(int(os.getenv("TEST_TIME", "20"))): - # send some CAN messages - if not JUNGLE_SPAM: + test_time = int(os.getenv("TEST_TIME", "20")) + for i in range(test_time + 2): + # send some CAN messages, then give the last ones time to come back + if not JUNGLE_SPAM and i < test_time: sent = send_random_can_messages(sendcan, random.randrange(2, 20)) for k, v in sent.items(): sent_msgs[k].extend(list(v)) @@ -74,8 +75,7 @@ class TestBoarddSpi(OpenpilotTestCase): continue key = (msg.address, msg.dat) assert key in sent_msgs[msg.src], f"got unexpected msg: {msg.src=} {msg.address=} {msg.dat=}" - # TODO: enable this - #sent_msgs[msg.src].remove(key) + sent_msgs[msg.src].remove(key) total_recv_count += 1 elif service == "pandaStates": assert len(m.pandaStates) == 1 @@ -107,6 +107,8 @@ class TestBoarddSpi(OpenpilotTestCase): with subtests.test(msg="CAN traffic"): print(f"Sent {total_sent_count} CAN messages, got {total_recv_count} back. {total_recv_count/(total_sent_count+1e-4):.2%} received") assert total_recv_count > 20 + # every message came back exactly once, despite the injected errors + assert all(len(v) == 0 for v in sent_msgs.values()) if __name__ == "__main__": diff --git a/panda b/panda index 92eb565169..835e4c31d9 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit 92eb565169fd553f4dfaf508c1a3f8dbc14fbfbf +Subproject commit 835e4c31d9514f9d06c341479237c35349e75136 From d3f0aab96f38b4ac8595ea6908b4c49af8cb3979 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Wed, 14 Oct 2026 08:47:35 -0700 Subject: [PATCH 231/270] pandad: simplify flashing loop (#39089) --- openpilot/selfdrive/pandad/pandad.py | 99 +++++++++++----------------- 1 file changed, 38 insertions(+), 61 deletions(-) diff --git a/openpilot/selfdrive/pandad/pandad.py b/openpilot/selfdrive/pandad/pandad.py index 6dbb398eed..8850893763 100755 --- a/openpilot/selfdrive/pandad/pandad.py +++ b/openpilot/selfdrive/pandad/pandad.py @@ -1,54 +1,48 @@ #!/usr/bin/env python3 # simple pandad wrapper that updates the panda first import os -import usb1 -import time import signal import subprocess -from panda import Panda, PandaDFU, PandaProtocolMismatch, McuType, FW_PATH +from panda import Panda, PandaDFU from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.hardware import HARDWARE from openpilot.common.swaglog import cloudlog -def get_expected_signature() -> bytes: - fn = os.path.join(FW_PATH, McuType.H7.config.app_fn) - return Panda.get_signature_from_firmware(fn) +def panda_ready() -> bool: + """Is the panda up and running the firmware we ship?""" + try: + if not Panda.wait_for_panda(None, timeout=5): + cloudlog.warning("no panda found") + return False + with Panda(cli=False) as p: + if p.bootstub: + cloudlog.warning(f"panda stuck in bootstub, version {p.get_version()}") + return False + if not p.up_to_date(): + cloudlog.warning(f"panda firmware out of date, version {p.get_version()}") + return False + return True + except Exception: + # e.g. SPI protocol mismatch with old firmware + cloudlog.exception("pandad.panda_check_failed") + return False -def flash_panda(panda_serial: str): - panda = Panda(panda_serial) - fw_signature = get_expected_signature() - internal_panda = panda.is_internal() - panda_version = "bootstub" if panda.bootstub else panda.get_version() - panda_signature = b"" if panda.bootstub else panda.get_signature() - cloudlog.warning(f"Panda {panda_serial} connected, version: {panda_version}, signature {panda_signature.hex()[:16]}, expected {fw_signature.hex()[:16]}") +def flash_panda() -> None: + """Reflash bootstub + app from the ST bootloader. Works from any state the panda can be in.""" + cloudlog.warning("flashing panda") + HARDWARE.recover_internal_panda() + assert Panda.wait_for_dfu(None, timeout=5), "panda didn't enter DFU" + with PandaDFU(None) as dfu: + dfu.recover() - if panda.bootstub or panda_signature != fw_signature: - cloudlog.info("Panda firmware out of date, update required") - panda.flash() - cloudlog.info("Done flashing") - - if panda.bootstub: - bootstub_version = panda.get_version() - cloudlog.info(f"Flashed firmware not booting, flashing development bootloader. {bootstub_version=}, {internal_panda=}") - if internal_panda: - HARDWARE.recover_internal_panda() - panda.recover(reset=(not internal_panda)) - cloudlog.info("Done flashing bootstub") - - if panda.bootstub: - cloudlog.info("Panda still not booting, exiting") - raise AssertionError - - panda_signature = panda.get_signature() - if panda_signature != fw_signature: - cloudlog.info("Version mismatch after flashing, exiting") - raise AssertionError - - panda.close() + assert Panda.wait_for_panda(None, timeout=5), "panda didn't boot into bootstub" + with Panda(cli=False) as p: + p.flash() + cloudlog.warning("done flashing panda") def main() -> None: @@ -79,34 +73,17 @@ def main() -> None: while not do_exit: try: cloudlog.event("pandad.flash_and_connect", count=count) - if (count % 2) == 0: - HARDWARE.reset_internal_panda() - else: - HARDWARE.recover_internal_panda() count += 1 - # Flash all Pandas in DFU mode - for serial in PandaDFU.list(): - cloudlog.info(f"Panda in DFU mode found, flashing recovery {serial}") - PandaDFU(serial).recover() - time.sleep(1) + HARDWARE.reset_internal_panda() + if not panda_ready(): + flash_panda() + assert panda_ready(), "panda not ready after flashing" - panda_serials = Panda.list() - if len(panda_serials): - assert len(panda_serials) == 1 - cloudlog.info(f"{len(panda_serials)} panda found, connecting - {panda_serials}") - flash_panda(panda_serials[0]) - - # run real pandad - os.environ['MANAGER_DAEMON'] = 'pandad' - process = subprocess.Popen(["./pandad"], cwd=os.path.join(BASEDIR, "openpilot/selfdrive/pandad")) - process.wait() - # TODO: wrap all panda exceptions in a base panda exception - except (usb1.USBErrorNoDevice, usb1.USBErrorPipe): - # a panda was disconnected while setting everything up. let's try again - cloudlog.exception("Panda USB exception while setting up") - except PandaProtocolMismatch: - cloudlog.exception("pandad.protocol_mismatch") + # run real pandad + os.environ['MANAGER_DAEMON'] = 'pandad' + process = subprocess.Popen(["./pandad"], cwd=os.path.join(BASEDIR, "openpilot/selfdrive/pandad")) + process.wait() except Exception: cloudlog.exception("pandad.uncaught_exception") From cb1d6f11a73a02f46d1fa6996a4a5ed76f119c38 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Wed, 14 Oct 2026 09:32:32 -0700 Subject: [PATCH 232/270] pandad: 1s wait for panda to come up (#39090) --- openpilot/selfdrive/pandad/pandad.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpilot/selfdrive/pandad/pandad.py b/openpilot/selfdrive/pandad/pandad.py index 8850893763..64d7ff33f7 100755 --- a/openpilot/selfdrive/pandad/pandad.py +++ b/openpilot/selfdrive/pandad/pandad.py @@ -14,7 +14,8 @@ from openpilot.common.swaglog import cloudlog def panda_ready() -> bool: """Is the panda up and running the firmware we ship?""" try: - if not Panda.wait_for_panda(None, timeout=5): + # a healthy panda answers on SPI within ~200ms of reset + if not Panda.wait_for_panda(None, timeout=1): cloudlog.warning("no panda found") return False with Panda(cli=False) as p: From 2b09bc94a9f52206173cece3b517a2a3262c258f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Wed, 14 Oct 2026 10:20:22 -0700 Subject: [PATCH 233/270] modeld: seed previous curvature when lateral control is inactive (#39091) * modeld: seed previous curvature when lateral control is inactive * modeld: reuse current curvature from carControl * Revert "modeld: reuse current curvature from carControl" This reverts commit 2f63062fd1b70e6e6f128b320bc7a8265a7d36e7. --- openpilot/selfdrive/modeld/modeld.py | 5 ++++- openpilot/selfdrive/test/process_replay/model_replay.py | 2 +- openpilot/selfdrive/test/process_replay/process_replay.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 86e5839bc5..b24ebd6812 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -293,7 +293,8 @@ def main(demo=False): # messaging pub_socks = ["modelV2", "drivingModelData", "cameraOdometry"] + (["chestnutGpuState"] if CHESTNUT else []) pm = PubMaster(pub_socks) - sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) + sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", + "carControl", "controlsState", "lateralDelay"]) publish_state = PublishState() params = Params() @@ -427,6 +428,8 @@ def main(demo=False): drivingdata_send = messaging.new_message('drivingModelData') posenet_send = messaging.new_message('cameraOdometry') + if not sm['carControl'].latActive: + prev_action.desiredCurvature = sm['controlsState'].curvature action = get_action_from_model(model_output, prev_action, lat_action_t, long_action_t, v_ego) prev_action = action fill_model_msg(modelv2_send, model_output, action, diff --git a/openpilot/selfdrive/test/process_replay/model_replay.py b/openpilot/selfdrive/test/process_replay/model_replay.py index 8f43f4066b..2ebb6f086f 100755 --- a/openpilot/selfdrive/test/process_replay/model_replay.py +++ b/openpilot/selfdrive/test/process_replay/model_replay.py @@ -153,7 +153,7 @@ def model_replay(lr, frs): # modeld is using frame pairs camera_states = {"narrowRoadCameraState", "wideRoadCameraState"} modeld_logs = trim_logs(lr, START_FRAME, END_FRAME, camera_states, - {"narrowRoadEncodeIdx", "wideRoadEncodeIdx", "carParams", "carState", "carControl", "can"}) + {"narrowRoadEncodeIdx", "wideRoadEncodeIdx", "carParams", "carState", "carControl", "controlsState", "can"}) dmodeld_logs = trim_logs(lr, START_FRAME, END_FRAME, {"cabinCameraState"}, {"cabinEncodeIdx", "carParams", "can"}) if not SEND_EXTRA_INPUTS: diff --git a/openpilot/selfdrive/test/process_replay/process_replay.py b/openpilot/selfdrive/test/process_replay/process_replay.py index 328b0bbbdd..03de7aee3f 100755 --- a/openpilot/selfdrive/test/process_replay/process_replay.py +++ b/openpilot/selfdrive/test/process_replay/process_replay.py @@ -550,7 +550,7 @@ CONFIGS = [ ProcessConfig( proc_name="modeld", pubs=["deviceState", "narrowRoadCameraState", "wideRoadCameraState", "extrinsicsCalibration", "lateralDelay", - "driverMonitoringState", "carState", "carControl"], + "driverMonitoringState", "carState", "carControl", "controlsState"], subs=["modelV2", "drivingModelData", "cameraOdometry"], ignore=["logMonoTime", "modelV2.frameDropPerc", "modelV2.modelExecutionTime", "drivingModelData.frameDropPerc", "drivingModelData.modelExecutionTime"], should_recv_callback=ModeldCameraSyncRcvCallback(), From 21edbbcfb99af22d92818b7f4eee4161036de722 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Wed, 14 Oct 2026 11:53:15 -0700 Subject: [PATCH 234/270] bump tinygrad (#39085) --- openpilot/selfdrive/modeld/modeld.py | 2 ++ openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl | 4 ++-- .../modeld/models/big_driving_warp_1344x760_tinygrad.pkl | 4 ++-- .../modeld/models/big_driving_warp_1928x1208_tinygrad.pkl | 4 ++-- tinygrad_repo | 2 +- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index b24ebd6812..92dde07fff 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -204,6 +204,8 @@ class ModelState: self.input_device.copy_from(self.input_host) self.input_queues['new_img'] = self.run_warp(**self.warp_inputs) self.run_model(output_buffers=self.outputs, **self.input_queues) + if self.chestnut and (err := Device[self.outputs['outputs'].device].error_state.host.view(fmt='q')[0]): + raise RuntimeError(f"chestnut failed with {err}") if after_enqueue is not None: after_enqueue() model_output = self.outputs['outputs'].numpy()[0] diff --git a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl index 0d35bf3f86..3784fedbb6 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:256f2178366479a211933701c03518621ed3970289e6a8a43e5a2bcb15e55b5f -size 799942179 +oid sha256:b3864072b1cf3c662d7f293fd16aab7318f2717388c3ddb721f5d57474acde6b +size 799950476 diff --git a/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl index 9be88a8d5d..712457b192 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:17e31388ad4412fb9045ced9280fc471a9933be2a25689156652a613d753954c -size 844637 +oid sha256:18c994e4500fe9f34daa8f33202a8606d3817e09e2c79ae8f9795e37159b5143 +size 850515 diff --git a/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl index 27ce69c82e..81bb93726c 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e761b629b569c7b08a8f0ca696bcf5143c055526e148395bdc189c6ba1a52fe1 -size 844380 +oid sha256:e30b5cada2c2a861999de6103c42053fae524850e30d63cd8f4d9e9bdd319e54 +size 850258 diff --git a/tinygrad_repo b/tinygrad_repo index d3f09c9bbd..02e698160e 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit d3f09c9bbd542fbfbe68c8569a1173550c408969 +Subproject commit 02e698160eed1751738212144a7ea2cfb160b3f1 From 66752e769fa60253917ea5637560a6989d8073f1 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Wed, 14 Oct 2026 11:53:36 -0700 Subject: [PATCH 235/270] bump panda (#39092) --- panda | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panda b/panda index 835e4c31d9..a59195163c 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit 835e4c31d9514f9d06c341479237c35349e75136 +Subproject commit a59195163c32a3a375ea6c86715dc08fd0984c1b From 6939d172e85e3b91f16d90cc8de72e8f938cc196 Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Wed, 14 Oct 2026 11:54:07 -0700 Subject: [PATCH 236/270] modeld: retry big model loading once (#39086) modeld: retry big model load once with an onroad cycle --- openpilot/common/params_keys.h | 1 + openpilot/selfdrive/modeld/modeld.py | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index cedf84f26b..a6fb704e7d 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -140,5 +140,6 @@ inline static std::unordered_map keys = { {"UptimeOnroad", {PERSISTENT, FLOAT, "0.0"}}, {"ChestnutActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, {"ChestnutLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, + {"ChestnutRetry", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, BOOL}}, {"Version", {PERSISTENT, STRING}}, }; diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 92dde07fff..bc9407f6a0 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -282,7 +282,13 @@ def main(demo=False): loader.start() loader.join(BIG_MODEL_TIMEOUT) model = big_model - params.put_bool("ChestnutActive", model is not None) + if model is None and not params.get_bool("ChestnutRetry"): + # a crank can cut the chestnut power during the load, retry once with an onroad cycle + cloudlog.error("big model load failed, cycling onroad to retry") + params.put_bool("ChestnutRetry", True) + params.put_bool("OnroadCycleRequested", True) + else: + params.put_bool("ChestnutActive", model is not None) small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or CHESTNUT else None if model is None: From 1e219d7785cfb7b7343c7f2366347fc86e7ce5f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Wed, 14 Oct 2026 13:43:30 -0700 Subject: [PATCH 237/270] MD v3 (#39087) 1365126a-e71e-4778-be00-8ab92dc82c09/12864 Co-authored-by: haraschax <6804392+haraschax@users.noreply.github.com> --- openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl index 3784fedbb6..afa4866d75 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b3864072b1cf3c662d7f293fd16aab7318f2717388c3ddb721f5d57474acde6b -size 799950476 +oid sha256:d4c7a3fcfc95813826ca871a80bea18df833aacb2eb6f5333e25a01e65b1a422 +size 799941965 From 4f3626dc9b4d929c9ee0c8feceb9c2b80e7a950c Mon Sep 17 00:00:00 2001 From: Daniel Koepping Date: Wed, 14 Oct 2026 14:27:52 -0700 Subject: [PATCH 238/270] bump to 0.11.3 (#39093) --- RELEASES.md | 6 +++++- openpilot/common/version.h | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index 36ddadb431..d0bc1e1d21 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,4 +1,8 @@ -Version 0.11.2 (2026-08-12) +Version 0.11.3 (2026-XX-XX) +======================== + + +Version 0.11.2 (2026-10-14) ======================= * New driving model * Big model with 880M parameters diff --git a/openpilot/common/version.h b/openpilot/common/version.h index 5a09588e22..16b8750286 100644 --- a/openpilot/common/version.h +++ b/openpilot/common/version.h @@ -1 +1 @@ -#define COMMA_VERSION "0.11.2" +#define COMMA_VERSION "0.11.3" From 690fa743b945973d594f2dfa143eee4fabc98a70 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Wed, 14 Oct 2026 15:06:26 -0700 Subject: [PATCH 239/270] ui: check raylib version for keyboard gradient (#39036) --- openpilot/system/ui/widgets/mici_keyboard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpilot/system/ui/widgets/mici_keyboard.py b/openpilot/system/ui/widgets/mici_keyboard.py index 97a33e95e7..4ebb940eed 100644 --- a/openpilot/system/ui/widgets/mici_keyboard.py +++ b/openpilot/system/ui/widgets/mici_keyboard.py @@ -353,10 +353,10 @@ class MiciKeyboard(Widget): # draw black circle behind selected key circle_alpha = int(self._selected_key_filter.x * 225) - try: + if rl.RAYLIB_VERSION_MAJOR >= 6: rl.draw_circle_gradient(rl.Vector2(key_x + key.rect.width / 2, key_y + key.rect.height / 2), SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, circle_alpha), rl.BLANK) - except (TypeError, RuntimeError): # AGNOS updater compatibility for raylib 5 + else: # AGNOS updater compatibility for raylib 5 rl.draw_circle_gradient(int(key_x + key.rect.width / 2), int(key_y + key.rect.height / 2), SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, circle_alpha), rl.BLANK) else: From 6eb63e045469a582f4c7b406705543e891113606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Wed, 14 Oct 2026 16:27:29 -0700 Subject: [PATCH 240/270] pigeond: set minimum satellite elevation to 0 degrees (#39083) Co-authored-by: haraschax <6804392+haraschax@users.noreply.github.com> --- openpilot/system/ubloxd/pigeond.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/system/ubloxd/pigeond.py b/openpilot/system/ubloxd/pigeond.py index df4632b118..45ec286399 100755 --- a/openpilot/system/ubloxd/pigeond.py +++ b/openpilot/system/ubloxd/pigeond.py @@ -233,7 +233,7 @@ def init_pigeon(pigeon: TTYPigeon) -> bool: pigeon.send_with_ack(b"\xB5\x62\x06\x08\x06\x00\x64\x00\x01\x00\x00\x00\x79\x10") # UBX-CFG-NAV5 (0x06 0x24) - pigeon.send_with_ack(b"\xB5\x62\x06\x24\x24\x00\x05\x00\x04\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x5A\x63") + pigeon.send_with_ack(b"\xB5\x62\x06\x24\x24\x00\x07\x00\x04\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x5C\xAB") # UBX-CFG-ODO (0x06 0x1E) pigeon.send_with_ack(b"\xB5\x62\x06\x1E\x14\x00\x00\x00\x00\x00\x01\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x3C\x37") From 24c26487f42b1d92cf96e3e23d686208e35e74e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Wed, 14 Oct 2026 16:45:37 -0700 Subject: [PATCH 241/270] Add configurable initial max speed (#39081) * Add configurable initial cruise speed * Use five-unit steps for initial max speed * Adjust initial max speed directly in settings * Limit initial max speed to supported cars and a 25 mph minimum * scroller * no spring * Revert "no spring" This reverts commit bf104344a6b93a9b4c574da6e049657f5767d227. * Revert "scroller" This reverts commit e4255e895edfd46d06676f3048eeeae2a781d689. * Clarify initial cruise speed parameter units * Simplify initial max speed controls * Repeat initial max speed adjustments while held * Snap initial cruise speed adjustments to five-unit steps * Clean up initial cruise speed initialization and repeat callback * Update toggles.py * Fix initial max speed setting description --------- Co-authored-by: Shane Smiskol --- openpilot/common/params_keys.h | 1 + openpilot/selfdrive/car/card.py | 6 +- openpilot/selfdrive/car/cruise.py | 4 +- .../selfdrive/car/tests/test_cruise_speed.py | 25 +++--- .../layouts/settings/initial_cruise_speed.py | 80 +++++++++++++++++++ .../selfdrive/ui/layouts/settings/toggles.py | 15 +++- .../ui/mici/layouts/settings/toggles.py | 27 ++++++- 7 files changed, 137 insertions(+), 21 deletions(-) create mode 100644 openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index a6fb704e7d..6d7b48c865 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -55,6 +55,7 @@ inline static std::unordered_map keys = { {"GsmRoaming", {PERSISTENT, BOOL}}, {"HardwareSerial", {PERSISTENT, STRING}}, {"HasAcceptedTerms", {PERSISTENT, STRING, "0"}}, + {"InitialCruiseSpeedKph", {PERSISTENT, INT, "40"}}, {"InstallDate", {PERSISTENT, TIME}}, {"IsDriverViewEnabled", {CLEAR_ON_MANAGER_START, BOOL}}, {"IsEngaged", {PERSISTENT, BOOL}}, diff --git a/openpilot/selfdrive/car/card.py b/openpilot/selfdrive/car/card.py index 86f8fbbde5..c14f2f724a 100755 --- a/openpilot/selfdrive/car/card.py +++ b/openpilot/selfdrive/car/card.py @@ -152,7 +152,7 @@ class Car: self.v_cruise_helper = VCruiseHelper(self.CP) self.is_metric = self.params.get_bool("IsMetric") - self.experimental_mode = self.params.get_bool("ExperimentalMode") + self.initial_cruise_speed = self.params.get("InitialCruiseSpeedKph", return_default=True) # card is driven by can recv, expected at 100Hz self.rk = Ratekeeper(100, print_delay_threshold=None) @@ -183,7 +183,7 @@ class Car: self.v_cruise_helper.update_v_cruise(CS, self.sm['carControl'].enabled, self.is_metric) if self.sm['carControl'].enabled and not self.CC_prev.enabled: # Use CarState w/ buttons from the step selfdrived enables on - self.v_cruise_helper.initialize_v_cruise(self.CS_prev, self.experimental_mode) + self.v_cruise_helper.initialize_v_cruise(self.CS_prev, self.initial_cruise_speed) # TODO: mirror the carState.cruiseState struct? CS.vCruise = float(self.v_cruise_helper.v_cruise_kph) @@ -255,7 +255,7 @@ class Car: def params_thread(self, evt): while not evt.is_set(): self.is_metric = self.params.get_bool("IsMetric") - self.experimental_mode = self.params.get_bool("ExperimentalMode") and self.CP.openpilotLongitudinalControl + self.initial_cruise_speed = self.params.get("InitialCruiseSpeedKph", return_default=True) time.sleep(0.1) def card_thread(self): diff --git a/openpilot/selfdrive/car/cruise.py b/openpilot/selfdrive/car/cruise.py index fd6ccf9518..ab0c238972 100644 --- a/openpilot/selfdrive/car/cruise.py +++ b/openpilot/selfdrive/car/cruise.py @@ -122,7 +122,7 @@ class VCruiseHelper: self.button_timers[b.type.raw] = 1 if b.pressed else 0 self.button_change_states[b.type.raw] = {"standstill": CS.cruiseState.standstill, "enabled": enabled} - def initialize_v_cruise(self, CS, experimental_mode: bool) -> None: + def initialize_v_cruise(self, CS, initial_speed: int) -> None: # initializing is handled by the PCM if self.CP.pcmCruise: return @@ -130,6 +130,6 @@ class VCruiseHelper: if any(b.type in (ButtonType.accelCruise, ButtonType.resumeCruise) for b in CS.buttonEvents) and self.v_cruise_initialized: self.v_cruise_kph = self.v_cruise_kph_last else: - self.v_cruise_kph = int(round(np.clip(CS.vEgo * CV.MS_TO_KPH, V_CRUISE_INITIAL, V_CRUISE_MAX))) + self.v_cruise_kph = int(round(np.clip(max(CS.vEgo * CV.MS_TO_KPH, initial_speed), V_CRUISE_INITIAL, V_CRUISE_MAX))) self.v_cruise_cluster_kph = self.v_cruise_kph diff --git a/openpilot/selfdrive/car/tests/test_cruise_speed.py b/openpilot/selfdrive/car/tests/test_cruise_speed.py index bf53747df8..cc8597e689 100644 --- a/openpilot/selfdrive/car/tests/test_cruise_speed.py +++ b/openpilot/selfdrive/car/tests/test_cruise_speed.py @@ -57,16 +57,16 @@ class TestVCruiseHelper(OpenpilotTestCase): for _ in range(2): self.v_cruise_helper.update_v_cruise(car.CarState(cruiseState={"available": False}), enabled=False, is_metric=False) - def enable(self, v_ego, experimental_mode): + def enable(self, v_ego): # Simulates user pressing set with a current speed - self.v_cruise_helper.initialize_v_cruise(car.CarState(vEgo=v_ego), experimental_mode) + self.v_cruise_helper.initialize_v_cruise(car.CarState(vEgo=v_ego), V_CRUISE_INITIAL) def test_adjust_speed(self): """ Asserts speed changes on falling edges of buttons. """ - self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False) + self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS) for btn in (ButtonType.accelCruise, ButtonType.decelCruise): for pressed in (True, False): @@ -90,7 +90,7 @@ class TestVCruiseHelper(OpenpilotTestCase): CS.buttonEvents = [ButtonEvent(type=ButtonType.decelCruise, pressed=pressed)] self.v_cruise_helper.update_v_cruise(CS, enabled=enabled, is_metric=False) if pressed: - self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False) + self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS) # Expected diff on enabling. Speed should not change on falling edge of pressed assert (not pressed) == (self.v_cruise_helper.v_cruise_kph == self.v_cruise_helper.v_cruise_kph_last) @@ -100,7 +100,7 @@ class TestVCruiseHelper(OpenpilotTestCase): Asserts we don't increment set speed if user presses resume/accel to exit cruise standstill. """ - self.enable(0, False) + self.enable(0) for standstill in (True, False): for pressed in (True, False): @@ -120,7 +120,7 @@ class TestVCruiseHelper(OpenpilotTestCase): for v_ego in np.linspace(0, 100, 101): self.reset_cruise_speed_state() - self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False) + self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS) # first decrement speed, then perform gas pressed logic expected_v_cruise_kph = self.v_cruise_helper.v_cruise_kph - IMPERIAL_INCREMENT @@ -141,11 +141,10 @@ class TestVCruiseHelper(OpenpilotTestCase): Asserts allowed cruise speeds on enabling with SET. """ - for experimental_mode in (True, False): - for v_ego in np.linspace(0, 100, 101): - self.reset_cruise_speed_state() - assert not self.v_cruise_helper.v_cruise_initialized + for v_ego in np.linspace(0, 100, 101): + self.reset_cruise_speed_state() + assert not self.v_cruise_helper.v_cruise_initialized - self.enable(float(v_ego), experimental_mode) - assert V_CRUISE_INITIAL <= self.v_cruise_helper.v_cruise_kph <= V_CRUISE_MAX - assert self.v_cruise_helper.v_cruise_initialized + self.enable(float(v_ego)) + assert V_CRUISE_INITIAL <= self.v_cruise_helper.v_cruise_kph <= V_CRUISE_MAX + assert self.v_cruise_helper.v_cruise_initialized diff --git a/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py b/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py new file mode 100644 index 0000000000..7555f35b56 --- /dev/null +++ b/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py @@ -0,0 +1,80 @@ +import math +import time +import pyray as rl + +from openpilot.common.constants import CV +from openpilot.common.params import Params +from openpilot.selfdrive.car.cruise import V_CRUISE_INITIAL, V_CRUISE_MAX +from openpilot.system.ui.lib.application import FontWeight, TextAlignment +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets.button import Button +from openpilot.system.ui.widgets.label import gui_label +from openpilot.system.ui.widgets.list_view import ItemAction + + +class RepeatButton(Button): + def __init__(self, text, callback, font_size): + super().__init__(text, callback, font_size=font_size, border_radius=16, text_padding=0) + self._repeat_at: float | None = None + self._repeated = False + + def cancel_repeat(self): + self._repeat_at = None + self._repeated = True + + def _handle_mouse_press(self, mouse_pos): + self._repeat_at = time.monotonic() + 0.5 + self._repeated = False + + def _handle_mouse_release(self, mouse_pos): + if not self._repeated: + super()._handle_mouse_release(mouse_pos) + + def _process_mouse_events(self): + super()._process_mouse_events() + if not self.is_pressed or not self._touch_valid(): + self.cancel_repeat() + elif self._repeat_at is not None and time.monotonic() >= self._repeat_at: + self._repeated = True + self._repeat_at = time.monotonic() + 0.2 + if self._click_callback: + self._click_callback() + + +class InitialCruiseSpeedControl(ItemAction): + def __init__(self, compact: bool = False): + super().__init__(width=322 if compact else 580) + self._params = Params() + self._button_size = 64 if compact else 100 + self._font_size = 36 if compact else 50 + + self._decrease = self._child(RepeatButton("-", lambda: self._adjust_speed(-5), self._font_size)) + self._increase = self._child(RepeatButton("+", lambda: self._adjust_speed(5), self._font_size)) + self._decrease.set_enabled(lambda: self.enabled and self._value > self._minimum) + self._increase.set_enabled(lambda: self.enabled and self._value < self._maximum) + self._decrease.set_touch_valid_callback(self._touch_valid) + self._increase.set_touch_valid_callback(self._touch_valid) + self.refresh() + + def refresh(self, is_metric: bool | None = None): + for button in (self._decrease, self._increase): + button.cancel_repeat() + self._metric = self._params.get_bool("IsMetric") if is_metric is None else is_metric + self._conversion = 1 if self._metric else CV.KPH_TO_MPH + self._minimum = round(V_CRUISE_INITIAL * self._conversion) + self._maximum = round(V_CRUISE_MAX * self._conversion) + self._value = max(self._minimum, round(self._params.get("InitialCruiseSpeedKph", return_default=True) * self._conversion)) + + def _adjust_speed(self, delta: int): + round_func = math.floor if delta > 0 else math.ceil + self._value = round_func(self._value / 5) * 5 + delta + self._params.put("InitialCruiseSpeedKph", round(self._value / self._conversion), block=True) + + def _render(self, rect): + size = self._button_size + y = rect.y + (rect.height - size) / 2 + self._decrease.render(rl.Rectangle(rect.x, y, size, size)) + self._increase.render(rl.Rectangle(rect.x + rect.width - size, y, size, size)) + text = f"{self._value} " + (tr("km/h") if self._metric else tr("mph")) + gui_label(rl.Rectangle(rect.x + size, y, rect.width - 2 * size, size), text, self._font_size, + color=rl.WHITE if self.enabled else rl.GRAY, font_weight=FontWeight.MEDIUM, alignment=TextAlignment.CENTER) diff --git a/openpilot/selfdrive/ui/layouts/settings/toggles.py b/openpilot/selfdrive/ui/layouts/settings/toggles.py index 06091782b1..a42a8a9e7d 100644 --- a/openpilot/selfdrive/ui/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/layouts/settings/toggles.py @@ -1,10 +1,11 @@ from openpilot.cereal import log from openpilot.common.params import Params, UnknownKeyName from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.list_view import multiple_button_item, toggle_item +from openpilot.system.ui.widgets.list_view import ListItem, multiple_button_item, toggle_item from openpilot.system.ui.widgets.scroller_tici import Scroller from openpilot.system.ui.lib.multilang import tr, tr_noop from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.layouts.settings.initial_cruise_speed import InitialCruiseSpeedControl PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants @@ -100,6 +101,14 @@ class TogglesLayout(Widget): ) self._toggles = {} + self._initial_cruise_speed = ListItem( + title=lambda: tr("Initial Max Speed"), + description=lambda: tr("Max speed when engaging openpilot. If you are already driving faster, your current speed is used. " + + "Resume restores the previous max speed. Changes apply the next time you engage."), + action_item=InitialCruiseSpeedControl(), + icon="speed_limit.png", + ) + self._initial_cruise_speed.set_visible(lambda: ui_state.CP is not None and ui_state.has_longitudinal_control and not ui_state.CP.pcmCruise) self._locked_toggles = set() for param, (title, desc, icon, needs_restart) in self._toggle_defs.items(): toggle = toggle_item( @@ -131,6 +140,7 @@ class TogglesLayout(Widget): # insert longitudinal personality after NDOG toggle if param == "DisengageOnAccelerator": self._toggles["LongitudinalPersonality"] = self._long_personality_setting + self._toggles["InitialCruiseSpeedKph"] = self._initial_cruise_speed self._update_experimental_mode_icon() self._scroller = Scroller(list(self._toggles.values()), line_separator=True, spacing=0) @@ -151,6 +161,7 @@ class TogglesLayout(Widget): def _update_toggles(self): ui_state.update_params() + self._initial_cruise_speed.action_item.refresh() e2e_description = tr( "

End-to-End Longitudinal Control


" + @@ -207,6 +218,8 @@ class TogglesLayout(Widget): self._params.put_bool(param, state, block=True) if param == "ExperimentalMode": self._update_experimental_mode_icon() + elif param == "IsMetric": + self._initial_cruise_speed.action_item.refresh() if self._toggle_defs[param][3]: self._params.put_bool("OnroadCycleRequested", True, block=True) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index 0cd552c6cd..097ffcbefc 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -1,14 +1,33 @@ from openpilot.cereal import log from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigParamControl, BigMultiParamToggle from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.layouts.settings.initial_cruise_speed import InitialCruiseSpeedControl PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants +class InitialCruiseSpeedButton(BigButton): + def __init__(self): + super().__init__("initial max speed", " ") + self._long_press_callback = None + self._control = self._child(InitialCruiseSpeedControl(compact=True)) + self._control.set_enabled(lambda: self.enabled) + self._control.set_touch_valid_callback(self._touch_valid) + + def _get_label_font_size(self): + return 36 + + def _draw_subtitle(self, rect): + self._control.render(rect) + + def refresh(self, is_metric: bool | None = None): + self._control.refresh(is_metric) + + class TogglesLayoutMici(NavScroller): def __init__(self): super().__init__() @@ -25,7 +44,9 @@ class TogglesLayoutMici(NavScroller): "Set speed is a maximum, not a target.\n" + "These are alpha features. Expect mistakes.\n" + "The path colors show acceleration and braking.") - is_metric_toggle = BigParamControl("use metric units", "IsMetric") + self._initial_cruise_speed = InitialCruiseSpeedButton() + self._initial_cruise_speed.set_visible(lambda: ui_state.CP is not None and ui_state.has_longitudinal_control and not ui_state.CP.pcmCruise) + is_metric_toggle = BigParamControl("use metric units", "IsMetric", toggle_callback=self._initial_cruise_speed.refresh) ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled", description="Warn when you drift across a detected lane line.\n" + "Only above 31 mph (50 km/h), with no turn signal.") @@ -44,6 +65,7 @@ class TogglesLayoutMici(NavScroller): self._scroller.add_widgets([ self._personality_toggle, self._experimental_btn, + self._initial_cruise_speed, is_metric_toggle, ldw_toggle, always_on_dm_toggle, @@ -88,6 +110,7 @@ class TogglesLayoutMici(NavScroller): def _update_toggles(self): ui_state.update_params() + self._initial_cruise_speed.refresh() # CP gating for experimental mode if ui_state.CP is not None: From fe7ccf9587d8e50e2450a865901d681f18fab508 Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Wed, 14 Oct 2026 17:11:30 -0700 Subject: [PATCH 242/270] Ratekeeper: resync after a stall instead of running catch-up frames (#39095) --- openpilot/common/realtime.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/openpilot/common/realtime.py b/openpilot/common/realtime.py index e0b9e8a3a5..792226b08c 100644 --- a/openpilot/common/realtime.py +++ b/openpilot/common/realtime.py @@ -91,8 +91,13 @@ class Ratekeeper: self.avg_dt.add_value(self._last_monitor_time - prev) lagged = False - remaining = self._next_frame_time - time.monotonic() - self._next_frame_time += self._interval + now = time.monotonic() + remaining = self._next_frame_time - now + if remaining < -self._interval: + # more than a frame behind (e.g. a stall): resync instead of running catch-up frames back-to-back + self._next_frame_time = now + self._interval + else: + self._next_frame_time += self._interval if self._print_delay_threshold is not None and remaining < -self._print_delay_threshold: print(f"{self._process_name} lagging by {-remaining * 1000:.2f} ms") lagged = True From f50108b4af28c04425b34290f4f1c2b882bd06f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Wed, 14 Oct 2026 21:33:00 -0700 Subject: [PATCH 243/270] A New Dawn --- openpilot/common/params_keys.h | 1 - openpilot/selfdrive/selfdrived/selfdrived.py | 6 +- openpilot/selfdrive/ui/layouts/home.py | 26 +------- openpilot/selfdrive/ui/layouts/main.py | 1 - .../ui/layouts/settings/developer.py | 1 - .../selfdrive/ui/layouts/settings/toggles.py | 49 +------------- openpilot/selfdrive/ui/mici/layouts/home.py | 41 ++---------- .../ui/mici/layouts/settings/developer.py | 1 - .../ui/mici/layouts/settings/toggles.py | 20 +----- .../ui/onroad/augmented_road_view.py | 4 +- openpilot/selfdrive/ui/onroad/exp_button.py | 63 ------------------ openpilot/selfdrive/ui/onroad/hud_renderer.py | 13 ---- openpilot/selfdrive/ui/ui_state.py | 2 - .../selfdrive/ui/widgets/exp_mode_button.py | 65 ------------------- 14 files changed, 14 insertions(+), 279 deletions(-) delete mode 100644 openpilot/selfdrive/ui/onroad/exp_button.py delete mode 100644 openpilot/selfdrive/ui/widgets/exp_mode_button.py diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 6d7b48c865..459189646d 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -40,7 +40,6 @@ inline static std::unordered_map keys = { {"DriverTooDistracted", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, BOOL}}, {"DriverLockoutCount", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, INT, "0"}}, {"AlphaLongitudinalEnabled", {PERSISTENT | DEVELOPMENT_ONLY, BOOL}}, - {"ExperimentalMode", {PERSISTENT, BOOL, "1"}}, {"FirmwareQueryDone", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"ForcePowerDown", {PERSISTENT, BOOL}}, {"GitBranch", {PERSISTENT, STRING}}, diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 52370f8c7f..8141a897a7 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -107,8 +107,6 @@ class SelfdriveD: # cleanup old params if not self.CP.alphaLongitudinalAvailable: self.params.remove("AlphaLongitudinalEnabled") - if not self.CP.openpilotLongitudinalControl: - self.params.remove("ExperimentalMode") self.CS_prev = car.CarState.new_message() self.AM = AlertManager() @@ -125,7 +123,6 @@ class SelfdriveD: self.events_prev = [] self.logged_comm_issue = None self.not_running_prev = None - self.experimental_mode = False self.personality = self.params.get("LongitudinalPersonality", return_default=True) self.recalibrating_seen = False self.dm_lockout_set = False @@ -533,7 +530,7 @@ class SelfdriveD: ss.active = self.active ss.state = self.state_machine.state ss.engageable = not self.events.contains(ET.NO_ENTRY) - ss.experimentalMode = self.experimental_mode + ss.experimentalMode = True ss.personality = self.personality ss.alertText1 = self.AM.current_alert.alert_text_1 @@ -570,7 +567,6 @@ class SelfdriveD: self.is_metric = self.params.get_bool("IsMetric") self.is_ldw_enabled = self.params.get_bool("IsLdwEnabled") self.disengage_on_accelerator = self.params.get_bool("DisengageOnAccelerator") - self.experimental_mode = self.params.get_bool("ExperimentalMode") and self.CP.openpilotLongitudinalControl self.personality = self.params.get("LongitudinalPersonality", return_default=True) time.sleep(0.1) diff --git a/openpilot/selfdrive/ui/layouts/home.py b/openpilot/selfdrive/ui/layouts/home.py index 613fc3de3d..8f7e394ffe 100644 --- a/openpilot/selfdrive/ui/layouts/home.py +++ b/openpilot/selfdrive/ui/layouts/home.py @@ -1,10 +1,8 @@ import time import pyray as rl -from collections.abc import Callable from enum import IntEnum from openpilot.common.params import Params from openpilot.selfdrive.ui.widgets.offroad_alerts import UpdateAlert, OffroadAlert -from openpilot.selfdrive.ui.widgets.exp_mode_button import ExperimentalModeButton from openpilot.selfdrive.ui.widgets.prime import PrimeWidget from openpilot.selfdrive.ui.widgets.setup import SetupWidget from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -39,7 +37,6 @@ class HomeLayout(Widget): self.current_state = HomeLayoutState.HOME self.last_refresh = 0 - self.settings_callback: Callable[[], None] | None = None self.update_available = False self.alert_count = 0 @@ -58,29 +55,20 @@ class HomeLayout(Widget): self._prime_widget = PrimeWidget() self._setup_widget = SetupWidget() - self._exp_mode_button = ExperimentalModeButton() self._setup_callbacks() def show_event(self): super().show_event() - self._exp_mode_button.show_event() self.last_refresh = time.monotonic() self._refresh() def _setup_callbacks(self): self.update_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME)) self.offroad_alert.set_dismiss_callback(lambda: self._set_state(HomeLayoutState.HOME)) - self._exp_mode_button.set_click_callback(lambda: self.settings_callback() if self.settings_callback else None) - - def set_settings_callback(self, callback: Callable): - self.settings_callback = callback def _set_state(self, state: HomeLayoutState): # propagate show/hide events if state != self.current_state: - if state == HomeLayoutState.HOME: - self._exp_mode_button.show_event() - if state in self._layout_widgets: self._layout_widgets[state].show_event() if self.current_state in self._layout_widgets: @@ -194,19 +182,7 @@ class HomeLayout(Widget): self._prime_widget.render(self.left_column_rect) def _render_right_column(self): - exp_height = 125 - exp_rect = rl.Rectangle( - self.right_column_rect.x, self.right_column_rect.y, self.right_column_rect.width, exp_height - ) - self._exp_mode_button.render(exp_rect) - - setup_rect = rl.Rectangle( - self.right_column_rect.x, - self.right_column_rect.y + exp_height + SPACING, - self.right_column_rect.width, - self.right_column_rect.height - exp_height - SPACING, - ) - self._setup_widget.render(setup_rect) + self._setup_widget.render(self.right_column_rect) def _refresh(self): self._version_text = self._get_version_text() diff --git a/openpilot/selfdrive/ui/layouts/main.py b/openpilot/selfdrive/ui/layouts/main.py index 9ce43b9e48..d0ebf5a38b 100644 --- a/openpilot/selfdrive/ui/layouts/main.py +++ b/openpilot/selfdrive/ui/layouts/main.py @@ -59,7 +59,6 @@ class MainLayout(Widget): on_flag=self._on_bookmark_clicked, open_settings=lambda: self.open_settings(PanelType.TOGGLES)) self._layouts[MainState.HOME]._setup_widget.set_open_settings_callback(lambda: self.open_settings(PanelType.FIREHOSE)) - self._layouts[MainState.HOME].set_settings_callback(lambda: self.open_settings(PanelType.TOGGLES)) self._layouts[MainState.SETTINGS].set_callbacks(on_close=self._set_mode_for_state) for layout in (self._layouts[MainState.ONROAD], self._home_body_layout): diff --git a/openpilot/selfdrive/ui/layouts/settings/developer.py b/openpilot/selfdrive/ui/layouts/settings/developer.py index 2b17436943..39ff3904a9 100644 --- a/openpilot/selfdrive/ui/layouts/settings/developer.py +++ b/openpilot/selfdrive/ui/layouts/settings/developer.py @@ -177,7 +177,6 @@ class DeveloperLayout(Widget): def _on_lat_maneuver_mode(self, state: bool): self._params.put_bool("LateralManeuverMode", state, block=True) - self._params.put_bool("ExperimentalMode", False, block=True) self._params.put_bool("JoystickDebugMode", False, block=True) self._joystick_toggle.action_item.set_state(False) self._params.put_bool("LongitudinalManeuverMode", False, block=True) diff --git a/openpilot/selfdrive/ui/layouts/settings/toggles.py b/openpilot/selfdrive/ui/layouts/settings/toggles.py index a42a8a9e7d..c448dba121 100644 --- a/openpilot/selfdrive/ui/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/layouts/settings/toggles.py @@ -36,7 +36,6 @@ class TogglesLayout(Widget): def __init__(self): super().__init__() self._params = Params() - self._is_release = self._params.get_bool("IsReleaseBranch") # param, title, desc, icon, needs_restart self._toggle_defs = { @@ -46,12 +45,6 @@ class TogglesLayout(Widget): "chffr_wheel.png", True, ), - "ExperimentalMode": ( - lambda: tr("Experimental Mode"), - "", - "experimental_white.png", - False, - ), "DisengageOnAccelerator": ( lambda: tr("Disengage on Accelerator Pedal"), DESCRIPTIONS["DisengageOnAccelerator"], @@ -142,7 +135,6 @@ class TogglesLayout(Widget): self._toggles["LongitudinalPersonality"] = self._long_personality_setting self._toggles["InitialCruiseSpeedKph"] = self._initial_cruise_speed - self._update_experimental_mode_icon() self._scroller = Scroller(list(self._toggles.values()), line_separator=True, spacing=0) ui_state.add_engaged_transition_callback(self._update_toggles) @@ -163,39 +155,8 @@ class TogglesLayout(Widget): ui_state.update_params() self._initial_cruise_speed.action_item.refresh() - e2e_description = tr( - "

End-to-End Longitudinal Control


" + - "Let the driving model control the gas and brakes. openpilot will drive as it thinks a human would, including stopping for red lights and stop signs. " + - "Since the driving model decides the speed to drive, the set speed will only act as an upper bound. Mistakes should be expected.
" - ) - if ui_state.CP is not None: - if ui_state.has_longitudinal_control: - self._toggles["ExperimentalMode"].action_item.set_enabled(True) - self._toggles["ExperimentalMode"].set_description(e2e_description) - self._long_personality_setting.action_item.set_enabled(True) - else: - # no long for now - self._toggles["ExperimentalMode"].action_item.set_enabled(False) - self._toggles["ExperimentalMode"].action_item.set_state(False) - self._long_personality_setting.action_item.set_enabled(False) - self._params.remove("ExperimentalMode") - - unavailable = tr("Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control.") - - long_desc = unavailable + " " + tr("openpilot longitudinal control may come in a future update.") - if ui_state.CP.alphaLongitudinalAvailable: - if self._is_release: - long_desc = unavailable + " " + tr("An alpha version of openpilot longitudinal control can be tested, along with " + - "Experimental mode, on non-release branches.") - else: - long_desc = tr("Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode.") - - self._toggles["ExperimentalMode"].set_description("" + long_desc + "

" + e2e_description) - else: - self._toggles["ExperimentalMode"].set_description(e2e_description) - - self._update_experimental_mode_icon() + self._long_personality_setting.action_item.set_enabled(ui_state.has_longitudinal_control) # TODO: make a param control list item so we don't need to manage internal state as much here # refresh toggles from params to mirror external changes @@ -210,15 +171,9 @@ class TogglesLayout(Widget): def _render(self, rect): self._scroller.render(rect) - def _update_experimental_mode_icon(self): - icon = "experimental.png" if self._toggles["ExperimentalMode"].action_item.get_state() else "experimental_white.png" - self._toggles["ExperimentalMode"].set_icon(icon) - def _toggle_callback(self, state: bool, param: str): self._params.put_bool(param, state, block=True) - if param == "ExperimentalMode": - self._update_experimental_mode_icon() - elif param == "IsMetric": + if param == "IsMetric": self._initial_cruise_speed.action_item.refresh() if self._toggle_defs[param][3]: diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index 14370d67c4..66a35fab92 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -2,7 +2,6 @@ from __future__ import annotations import datetime import math -import time from openpilot.cereal import log import pyray as rl @@ -127,13 +126,8 @@ class MiciHomeLayout(Widget): self._on_alerts_click: Callable | None = None self._alert_count_callback: Callable[[], int] | None = None - self._mouse_down_t: None | float = None - self._did_long_press = False - self._is_pressed_prev = False - self._version_text = self._get_version_text() - self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) self._usb_icon = IconWidget("icons_mici/usb.png", (62, 40)) self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (54, 40)) self._chestnut_loading_icon = IconWidget("icons_mici/chestnut.png", (68, 40)) @@ -146,7 +140,6 @@ class MiciHomeLayout(Widget): self._status_bar_layout = HBoxLayout([ IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9), NetworkIcon(), - self._experimental_icon, self._usb_icon, self._chestnut_icon, self._chestnut_loading_icon, @@ -162,23 +155,6 @@ class MiciHomeLayout(Widget): self._branch_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, scroll=True) self._version_commit_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) - def _update_state(self): - if self.is_pressed and not self._is_pressed_prev: - self._mouse_down_t = time.monotonic() - elif not self.is_pressed and self._is_pressed_prev: - self._mouse_down_t = None - self._did_long_press = False - self._is_pressed_prev = self.is_pressed - - if self._mouse_down_t is not None: - if time.monotonic() - self._mouse_down_t > 0.5: - # long gating for experimental mode - only allow toggle if longitudinal control is available - if ui_state.has_longitudinal_control: - ui_state.experimental_mode = not ui_state.experimental_mode - ui_state.params.put("ExperimentalMode", ui_state.experimental_mode, block=True) - self._mouse_down_t = None - self._did_long_press = True - def set_callbacks(self, on_settings: Callable | None = None, on_alerts: Callable | None = None, alert_count_callback: Callable[[], int] | None = None, alert_icon_callback: Callable[[], rl.Texture | None] | None = None): @@ -188,15 +164,13 @@ class MiciHomeLayout(Widget): self._alerts_pill.set_alert_count_callback(alert_count_callback, alert_icon_callback) def _handle_mouse_release(self, mouse_pos: MousePos): - if not self._did_long_press: - relative_x = mouse_pos.x - self.rect.x - has_alerts = self._alert_count_callback and self._alert_count_callback() > 0 - if has_alerts and relative_x > self.rect.width - ALERTS_ZONE_WIDTH: - if self._on_alerts_click: - self._on_alerts_click() - elif self._on_settings_click: - self._on_settings_click() - self._did_long_press = False + relative_x = mouse_pos.x - self.rect.x + has_alerts = self._alert_count_callback and self._alert_count_callback() > 0 + if has_alerts and relative_x > self.rect.width - ALERTS_ZONE_WIDTH: + if self._on_alerts_click: + self._on_alerts_click() + elif self._on_settings_click: + self._on_settings_click() def _get_version_text(self) -> tuple[str, str, str, str] | None: version = ui_state.params.get("Version") @@ -249,7 +223,6 @@ class MiciHomeLayout(Widget): usb_connected = ui_state.usb_connected usb_unknown = ui_state.usb_unknown chestnut_state = ui_state.chestnut_state - self._experimental_icon.set_visible(ui_state.experimental_mode) self._usb_icon.set_visible(usb_connected and usb_unknown) self._chestnut_icon.set_visible(not usb_unknown and chestnut_state not in (ChestnutState.LOADING, ChestnutState.UNCOMPILED, ChestnutState.FAILED) and diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py index 32e146b245..3c11219538 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py @@ -184,7 +184,6 @@ class DeveloperLayoutMici(NavScroller): def _on_lat_maneuver_mode(self, state: bool): ui_state.params.put_bool("LateralManeuverMode", state, block=True) - ui_state.params.put_bool("ExperimentalMode", False, block=True) ui_state.params.put_bool("JoystickDebugMode", False, block=True) self._joystick_toggle.set_checked(False) ui_state.params.put_bool("LongitudinalManeuverMode", False, block=True) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index 097ffcbefc..a44dacd7ab 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -37,13 +37,6 @@ class TogglesLayoutMici(NavScroller): "Aggressive follows closer, with firmer gas and braking.\n" + "Relaxed leaves more space.\n" + "Use the steering wheel distance button on supported cars.") - self._experimental_btn = BigParamControl("experimental mode", "ExperimentalMode", - description_icon=gui_app.texture("icons_mici/experimental_mode.png", 64, 64), - description="Let the driving model control gas and brakes.\n" + - "Includes stopping for red lights and stop signs.\n" + - "Set speed is a maximum, not a target.\n" + - "These are alpha features. Expect mistakes.\n" + - "The path colors show acceleration and braking.") self._initial_cruise_speed = InitialCruiseSpeedButton() self._initial_cruise_speed.set_visible(lambda: ui_state.CP is not None and ui_state.has_longitudinal_control and not ui_state.CP.pcmCruise) is_metric_toggle = BigParamControl("use metric units", "IsMetric", toggle_callback=self._initial_cruise_speed.refresh) @@ -64,7 +57,6 @@ class TogglesLayoutMici(NavScroller): self._scroller.add_widgets([ self._personality_toggle, - self._experimental_btn, self._initial_cruise_speed, is_metric_toggle, ldw_toggle, @@ -76,7 +68,6 @@ class TogglesLayoutMici(NavScroller): # Toggle lists self._refresh_toggles = ( - ("ExperimentalMode", self._experimental_btn), ("IsMetric", is_metric_toggle), ("IsLdwEnabled", ldw_toggle), ("AlwaysOnDM", always_on_dm_toggle), @@ -112,17 +103,8 @@ class TogglesLayoutMici(NavScroller): ui_state.update_params() self._initial_cruise_speed.refresh() - # CP gating for experimental mode if ui_state.CP is not None: - if ui_state.has_longitudinal_control: - self._experimental_btn.set_visible(True) - self._personality_toggle.set_visible(True) - else: - # no long for now - self._experimental_btn.set_visible(False) - self._experimental_btn.set_checked(False) - self._personality_toggle.set_visible(False) - ui_state.params.remove("ExperimentalMode") + self._personality_toggle.set_visible(ui_state.has_longitudinal_control) # Refresh toggles from params to mirror external changes for key, item in self._refresh_toggles: diff --git a/openpilot/selfdrive/ui/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/onroad/augmented_road_view.py index a16fb3607b..22d7d61ff3 100644 --- a/openpilot/selfdrive/ui/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/onroad/augmented_road_view.py @@ -94,11 +94,11 @@ class AugmentedRoadView(CameraView): self._draw_border(rect) def _handle_mouse_press(self, _): - if not self._hud_renderer.user_interacting() and self._click_callback is not None: + if self._click_callback is not None: self._click_callback() def _handle_mouse_release(self, _): - # We only call click callback on press if not interacting with HUD + # We only call click callback on press pass def _draw_border(self, rect: rl.Rectangle): diff --git a/openpilot/selfdrive/ui/onroad/exp_button.py b/openpilot/selfdrive/ui/onroad/exp_button.py deleted file mode 100644 index a122394c88..0000000000 --- a/openpilot/selfdrive/ui/onroad/exp_button.py +++ /dev/null @@ -1,63 +0,0 @@ -import time -import pyray as rl -from openpilot.common.params import Params -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 - - -class ExpButton(Widget): - def __init__(self, button_size: int, icon_size: int): - super().__init__() - self._params = Params() - self._experimental_mode: bool = False - self._engageable: bool = False - - # State hold mechanism - self._hold_duration = 2.0 # seconds - self._held_mode: bool | None = None - self._hold_end_time: float | None = None - - self._white_color: rl.Color = rl.Color(255, 255, 255, 255) - self._black_bg: rl.Color = rl.Color(0, 0, 0, 166) - self._txt_wheel: rl.Texture = gui_app.texture('icons/chffr_wheel.png', icon_size, icon_size) - self._txt_exp: rl.Texture = gui_app.texture('icons/experimental.png', icon_size, icon_size) - self._rect = rl.Rectangle(0, 0, button_size, button_size) - - def set_rect(self, rect: rl.Rectangle) -> None: - self._rect.x, self._rect.y = rect.x, rect.y - - def _update_state(self) -> None: - selfdrive_state = ui_state.sm["selfdriveState"] - self._experimental_mode = selfdrive_state.experimentalMode - self._engageable = selfdrive_state.engageable or selfdrive_state.enabled - - def _handle_mouse_release(self, _): - super()._handle_mouse_release(_) - if ui_state.has_longitudinal_control: - new_mode = not self._experimental_mode - self._params.put_bool("ExperimentalMode", new_mode) - - # Hold new state temporarily - self._held_mode = new_mode - self._hold_end_time = time.monotonic() + self._hold_duration - - def _render(self, rect: rl.Rectangle) -> None: - center_x = int(self._rect.x + self._rect.width // 2) - center_y = int(self._rect.y + self._rect.height // 2) - - self._white_color.a = 180 if self.is_pressed or not self._engageable else 255 - - texture = self._txt_exp if self._held_or_actual_mode() else self._txt_wheel - rl.draw_circle(center_x, center_y, self._rect.width / 2, self._black_bg) - rl.draw_texture_ex(texture, rl.Vector2(center_x - texture.width / 2, center_y - texture.height / 2), 0.0, 1.0, self._white_color) - - def _held_or_actual_mode(self): - now = time.monotonic() - if self._hold_end_time and now < self._hold_end_time: - return self._held_mode - - if self._hold_end_time and now >= self._hold_end_time: - self._hold_end_time = self._held_mode = None - - return self._experimental_mode diff --git a/openpilot/selfdrive/ui/onroad/hud_renderer.py b/openpilot/selfdrive/ui/onroad/hud_renderer.py index 73df8b3961..ccc20c0aab 100644 --- a/openpilot/selfdrive/ui/onroad/hud_renderer.py +++ b/openpilot/selfdrive/ui/onroad/hud_renderer.py @@ -1,7 +1,6 @@ import pyray as rl from dataclasses import dataclass from openpilot.common.constants import CV -from openpilot.selfdrive.ui.onroad.exp_button import ExpButton from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.multilang import tr @@ -17,12 +16,9 @@ CRUISE_DISABLED_CHAR = '–' @dataclass(frozen=True) class UIConfig: header_height: int = 300 - border_size: int = 30 - button_size: int = 192 set_speed_width_metric: int = 200 set_speed_width_imperial: int = 172 set_speed_height: int = 204 - wheel_icon_size: int = 144 @dataclass(frozen=True) @@ -70,8 +66,6 @@ class HudRenderer(Widget): self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD) self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM) - self._exp_button: ExpButton = ExpButton(UI_CONFIG.button_size, UI_CONFIG.wheel_icon_size) - def _update_state(self) -> None: """Update HUD state based on car state and controls state.""" sm = ui_state.sm @@ -117,13 +111,6 @@ class HudRenderer(Widget): self._draw_current_speed(rect) - button_x = rect.x + rect.width - UI_CONFIG.border_size - UI_CONFIG.button_size - button_y = rect.y + UI_CONFIG.border_size - self._exp_button.render(rl.Rectangle(button_x, button_y, UI_CONFIG.button_size, UI_CONFIG.button_size)) - - def user_interacting(self) -> bool: - return self._exp_button.is_pressed - def _draw_set_speed(self, rect: rl.Rectangle) -> None: """Draw the MAX speed indicator box.""" set_speed_width = UI_CONFIG.set_speed_width_metric if ui_state.is_metric else UI_CONFIG.set_speed_width_imperial diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index 0c90a4830c..c093253771 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -88,7 +88,6 @@ class UIState: self.is_metric: bool = self.params.get_bool("IsMetric") self.is_release = self.params.get_bool("IsReleaseBranch") self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM") - self.experimental_mode: bool = self.params.get_bool("ExperimentalMode") self.navigation_enabled: bool = self.params.get_bool("NavigationEnabled") self.nav_destination_set: bool = self.params.get("NavDestination") is not None self.chestnut_present: bool = False @@ -251,7 +250,6 @@ class UIState: self.recording_audio = self.params.get_bool("RecordAudio") and self.started self.is_metric = self.params.get_bool("IsMetric") self.always_on_dm = self.params.get_bool("AlwaysOnDM") - self.experimental_mode = self.params.get_bool("ExperimentalMode") self.navigation_enabled = self.params.get_bool("NavigationEnabled") self.nav_destination_set = self.params.get("NavDestination") is not None self.chestnut_active = self.params.get("ChestnutActive") diff --git a/openpilot/selfdrive/ui/widgets/exp_mode_button.py b/openpilot/selfdrive/ui/widgets/exp_mode_button.py deleted file mode 100644 index 0b5bff7da4..0000000000 --- a/openpilot/selfdrive/ui/widgets/exp_mode_button.py +++ /dev/null @@ -1,65 +0,0 @@ -import pyray as rl -from openpilot.common.params import Params -from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE -from openpilot.system.ui.lib.multilang import tr -from openpilot.system.ui.widgets import Widget - - -class ExperimentalModeButton(Widget): - def __init__(self): - super().__init__() - - self.img_width = 80 - self.horizontal_padding = 25 - self.button_height = 125 - - self.params = Params() - self.experimental_mode = self.params.get_bool("ExperimentalMode") - - self.chill_pixmap = gui_app.texture("icons/couch.png", self.img_width, self.img_width) - self.experimental_pixmap = gui_app.texture("icons/experimental_grey.png", self.img_width, self.img_width) - - def show_event(self): - super().show_event() - self.experimental_mode = self.params.get_bool("ExperimentalMode") - - def _get_gradient_colors(self): - alpha = 0xCC if self.is_pressed else 0xFF - - if self.experimental_mode: - return rl.Color(255, 155, 63, alpha), rl.Color(219, 56, 34, alpha) - else: - return rl.Color(20, 255, 171, alpha), rl.Color(35, 149, 255, alpha) - - def _draw_gradient_background(self, rect): - start_color, end_color = self._get_gradient_colors() - rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), int(rect.width), int(rect.height), - start_color, end_color) - - def _render(self, rect): - rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) - self._draw_gradient_background(rect) - rl.draw_rectangle_rounded_lines_ex(self._rect, 0.19, 10, 5, rl.BLACK) - rl.end_scissor_mode() - - # Draw vertical separator line - line_x = rect.x + rect.width - self.img_width - (2 * self.horizontal_padding) - separator_color = rl.Color(0, 0, 0, 77) # 0x4d = 77 - rl.draw_line_ex(rl.Vector2(line_x, rect.y), rl.Vector2(line_x, rect.y + rect.height), 3, separator_color) - - # Draw text label (left aligned) - text = tr("EXPERIMENTAL MODE ON") if self.experimental_mode else tr("CHILL MODE ON") - text_x = rect.x + self.horizontal_padding - text_y = rect.y + rect.height / 2 - 45 * FONT_SCALE // 2 # Center vertically - - rl.draw_text_ex(gui_app.font(FontWeight.NORMAL), text, rl.Vector2(int(text_x), int(text_y)), 45, 0, rl.BLACK) - - # Draw icon (right aligned) - icon_x = rect.x + rect.width - self.horizontal_padding - self.img_width - icon_y = rect.y + (rect.height - self.img_width) / 2 - icon_rect = rl.Rectangle(icon_x, icon_y, self.img_width, self.img_width) - - # Draw current mode icon - current_icon = self.experimental_pixmap if self.experimental_mode else self.chill_pixmap - source_rect = rl.Rectangle(0, 0, current_icon.width, current_icon.height) - rl.draw_texture_pro(current_icon, source_rect, icon_rect, rl.Vector2(0, 0), 0, rl.WHITE) From 75b2ccca60ec4044d4b9aa6c2662f60cc2831642 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Thu, 15 Oct 2026 01:22:01 -0700 Subject: [PATCH 244/270] Minor initial set speed fixes (#39099) * fix bugs * cmt --- openpilot/selfdrive/selfdrived/selfdrived.py | 2 +- openpilot/selfdrive/ui/layouts/settings/developer.py | 2 +- .../ui/layouts/settings/initial_cruise_speed.py | 4 ++-- .../selfdrive/ui/mici/layouts/settings/toggles.py | 12 ++++++++++-- openpilot/system/ui/widgets/button.py | 6 ++++++ openpilot/system/ui/widgets/list_view.py | 11 ++++++----- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 8141a897a7..e08576409e 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -530,7 +530,7 @@ class SelfdriveD: ss.active = self.active ss.state = self.state_machine.state ss.engageable = not self.events.contains(ET.NO_ENTRY) - ss.experimentalMode = True + ss.experimentalMode = self.CP.openpilotLongitudinalControl ss.personality = self.personality ss.alertText1 = self.AM.current_alert.alert_text_1 diff --git a/openpilot/selfdrive/ui/layouts/settings/developer.py b/openpilot/selfdrive/ui/layouts/settings/developer.py index 39ff3904a9..8e73382888 100644 --- a/openpilot/selfdrive/ui/layouts/settings/developer.py +++ b/openpilot/selfdrive/ui/layouts/settings/developer.py @@ -22,7 +22,7 @@ DESCRIPTIONS = { 'alpha_longitudinal': tr_noop( "WARNING: openpilot longitudinal control is in alpha for this car and may disable Automatic Emergency Braking (AEB).

" + "On this car, openpilot defaults to the car's built-in ACC instead of openpilot's longitudinal control. " + - "Enable this to switch to openpilot longitudinal control. Enabling Experimental mode is recommended when enabling openpilot longitudinal control alpha. " + + "Enable this to switch to openpilot longitudinal control. " + "Changing this setting will restart openpilot if the car is powered on." ), } diff --git a/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py b/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py index 7555f35b56..fb30e2c524 100644 --- a/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py +++ b/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py @@ -7,14 +7,14 @@ from openpilot.common.params import Params from openpilot.selfdrive.car.cruise import V_CRUISE_INITIAL, V_CRUISE_MAX from openpilot.system.ui.lib.application import FontWeight, TextAlignment from openpilot.system.ui.lib.multilang import tr -from openpilot.system.ui.widgets.button import Button +from openpilot.system.ui.widgets.button import Button, ButtonStyle from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets.list_view import ItemAction class RepeatButton(Button): def __init__(self, text, callback, font_size): - super().__init__(text, callback, font_size=font_size, border_radius=16, text_padding=0) + super().__init__(text, callback, font_size=font_size, button_style=ButtonStyle.PILL, border_radius=16, text_padding=0) self._repeat_at: float | None = None self._repeated = False diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index a44dacd7ab..6c4239537b 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -12,11 +12,19 @@ PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants class InitialCruiseSpeedButton(BigButton): def __init__(self): - super().__init__("initial max speed", " ") - self._long_press_callback = None + super().__init__("initial max speed", " ", description="Max speed when engaging openpilot.\n" + + "If you are already driving faster, your current speed is used.\n" + + "Resume restores the previous max speed.") self._control = self._child(InitialCruiseSpeedControl(compact=True)) self._control.set_enabled(lambda: self.enabled) self._control.set_touch_valid_callback(self._touch_valid) + self._show_description = self._long_press_callback + self.set_long_press_callback(self._on_long_press) + + def _on_long_press(self): + # a hold on the speed control repeats a step + if not self._control.is_pressed and self._show_description is not None: + self._show_description() def _get_label_font_size(self): return 36 diff --git a/openpilot/system/ui/widgets/button.py b/openpilot/system/ui/widgets/button.py index c031b2f141..cca48cb190 100644 --- a/openpilot/system/ui/widgets/button.py +++ b/openpilot/system/ui/widgets/button.py @@ -21,6 +21,7 @@ class ButtonStyle(IntEnum): NO_EFFECT = 6 KEYBOARD = 7 FORGET_WIFI = 8 + PILL = 11 # For pill buttons in list items, for example the multiple button selector ICON_PADDING = 15 @@ -39,10 +40,12 @@ BUTTON_TEXT_COLOR = { ButtonStyle.NO_EFFECT: rl.Color(228, 228, 228, 255), ButtonStyle.KEYBOARD: rl.Color(221, 221, 221, 255), ButtonStyle.FORGET_WIFI: rl.Color(51, 51, 51, 255), + ButtonStyle.PILL: rl.Color(228, 228, 228, 255), } BUTTON_DISABLED_TEXT_COLORS = { ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.WHITE, + ButtonStyle.PILL: rl.Color(150, 150, 150, 255), } BUTTON_BACKGROUND_COLORS = { @@ -57,6 +60,7 @@ BUTTON_BACKGROUND_COLORS = { ButtonStyle.NO_EFFECT: rl.Color(51, 51, 51, 255), ButtonStyle.KEYBOARD: rl.Color(68, 68, 68, 255), ButtonStyle.FORGET_WIFI: rl.Color(189, 189, 189, 255), + ButtonStyle.PILL: rl.Color(57, 57, 57, 255), } BUTTON_PRESSED_BACKGROUND_COLORS = { @@ -71,10 +75,12 @@ BUTTON_PRESSED_BACKGROUND_COLORS = { ButtonStyle.NO_EFFECT: rl.Color(51, 51, 51, 255), ButtonStyle.KEYBOARD: rl.Color(51, 51, 51, 255), ButtonStyle.FORGET_WIFI: rl.Color(130, 130, 130, 255), + ButtonStyle.PILL: rl.Color(74, 74, 74, 255), } BUTTON_DISABLED_BACKGROUND_COLORS = { ButtonStyle.TRANSPARENT_WHITE_TEXT: rl.BLANK, + ButtonStyle.PILL: rl.Color(57, 57, 57, 150), } diff --git a/openpilot/system/ui/widgets/list_view.py b/openpilot/system/ui/widgets/list_view.py index d88473a491..696a77caa2 100644 --- a/openpilot/system/ui/widgets/list_view.py +++ b/openpilot/system/ui/widgets/list_view.py @@ -7,7 +7,8 @@ from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, T from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.button import Button, ButtonStyle +from openpilot.system.ui.widgets.button import (Button, ButtonStyle, BUTTON_TEXT_COLOR, BUTTON_DISABLED_TEXT_COLORS, BUTTON_BACKGROUND_COLORS, + BUTTON_PRESSED_BACKGROUND_COLORS, BUTTON_DISABLED_BACKGROUND_COLORS) from openpilot.system.ui.widgets.toggle import Toggle, WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType @@ -242,12 +243,12 @@ class MultipleButtonAction(ItemAction): if is_selected: bg_color = rl.Color(51, 171, 76, 255) # Green elif is_pressed: - bg_color = rl.Color(74, 74, 74, 255) # Dark gray + bg_color = BUTTON_PRESSED_BACKGROUND_COLORS[ButtonStyle.PILL] else: - bg_color = rl.Color(57, 57, 57, 255) # Gray + bg_color = BUTTON_BACKGROUND_COLORS[ButtonStyle.PILL] if not self.enabled: - bg_color = rl.Color(bg_color.r, bg_color.g, bg_color.b, 150) # Dim + bg_color = rl.Color(bg_color.r, bg_color.g, bg_color.b, BUTTON_DISABLED_BACKGROUND_COLORS[ButtonStyle.PILL].a) # Draw button rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color) @@ -257,7 +258,7 @@ class MultipleButtonAction(ItemAction): text_size = measure_text_cached(self._font, text, 40) text_x = button_x + (self.button_width - text_size.x) / 2 text_y = button_y + (BUTTON_HEIGHT - text_size.y) / 2 - text_color = rl.Color(228, 228, 228, 255) if self.enabled else rl.Color(150, 150, 150, 255) + text_color = BUTTON_TEXT_COLOR[ButtonStyle.PILL] if self.enabled else BUTTON_DISABLED_TEXT_COLORS[ButtonStyle.PILL] rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 40, 0, text_color) def _handle_mouse_release(self, mouse_pos: MousePos): From f7bc31f208848aeafeda0800fa4a13c4b541d871 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:41:21 -0700 Subject: [PATCH 245/270] ci: no hook (#2055) --- .github/workflows/build-default-models.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build-default-models.yaml b/.github/workflows/build-default-models.yaml index 700c7bba8f..c51bf5cd74 100644 --- a/.github/workflows/build-default-models.yaml +++ b/.github/workflows/build-default-models.yaml @@ -186,7 +186,6 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive - lfs: true - name: Pull big PKL via LFS run: git lfs pull -I "${{ env.BIG_PKL }}" From 41acce17fa226cb40af8f3e17f64259784731b01 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Thu, 15 Oct 2026 15:59:39 -0700 Subject: [PATCH 246/270] Rename to following distance (#39100) rename --- openpilot/selfdrive/selfdrived/events.py | 2 +- openpilot/selfdrive/ui/layouts/settings/toggles.py | 4 ++-- openpilot/selfdrive/ui/mici/layouts/settings/toggles.py | 2 +- openpilot/selfdrive/ui/translations/app.pot | 2 +- openpilot/selfdrive/ui/translations/app_de.po | 4 ++-- openpilot/selfdrive/ui/translations/app_en.po | 4 ++-- openpilot/selfdrive/ui/translations/app_es.po | 4 ++-- openpilot/selfdrive/ui/translations/app_fr.po | 4 ++-- openpilot/selfdrive/ui/translations/app_ja.po | 4 ++-- openpilot/selfdrive/ui/translations/app_ko.po | 4 ++-- openpilot/selfdrive/ui/translations/app_pt-BR.po | 4 ++-- openpilot/selfdrive/ui/translations/app_th.po | 4 ++-- openpilot/selfdrive/ui/translations/app_tr.po | 4 ++-- openpilot/selfdrive/ui/translations/app_uk.po | 4 ++-- openpilot/selfdrive/ui/translations/app_zh-CHS.po | 4 ++-- openpilot/selfdrive/ui/translations/app_zh-CHT.po | 4 ++-- 16 files changed, 29 insertions(+), 29 deletions(-) diff --git a/openpilot/selfdrive/selfdrived/events.py b/openpilot/selfdrive/selfdrived/events.py index b430b91c82..d0a7bf3122 100755 --- a/openpilot/selfdrive/selfdrived/events.py +++ b/openpilot/selfdrive/selfdrived/events.py @@ -369,7 +369,7 @@ def longitudinal_maneuver_alert(CP: car.CarParams, CS: car.CarState, sm: messagi def personality_changed_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: personality = str(personality).title() - return NormalPermanentAlert(f"Driving Personality: {personality}", duration=1.5) + return NormalPermanentAlert(f"Following Distance: {personality}", duration=1.5) def invalid_lkas_setting_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: diff --git a/openpilot/selfdrive/ui/layouts/settings/toggles.py b/openpilot/selfdrive/ui/layouts/settings/toggles.py index c448dba121..4d6c2812fc 100644 --- a/openpilot/selfdrive/ui/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/layouts/settings/toggles.py @@ -18,7 +18,7 @@ DESCRIPTIONS = { "DisengageOnAccelerator": tr_noop("When enabled, pressing the accelerator pedal will disengage openpilot."), "LongitudinalPersonality": tr_noop( "Standard is recommended. In aggressive mode, openpilot will follow lead cars closer and be more aggressive with the gas and brake. " + - "In relaxed mode openpilot will stay further away from lead cars. On supported cars, you can cycle through these personalities with " + + "In relaxed mode openpilot will stay further away from lead cars. On supported cars, you can cycle through these settings with " + "your steering wheel distance button." ), "IsLdwEnabled": tr_noop( @@ -84,7 +84,7 @@ class TogglesLayout(Widget): } self._long_personality_setting = multiple_button_item( - lambda: tr("Driving Personality"), + lambda: tr("Following Distance"), lambda: tr(DESCRIPTIONS["LongitudinalPersonality"]), buttons=[lambda: tr("Aggressive"), lambda: tr("Standard"), lambda: tr("Relaxed")], button_width=255, diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index 6c4239537b..a491e2ac37 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -40,7 +40,7 @@ class TogglesLayoutMici(NavScroller): def __init__(self): super().__init__() - self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"], + self._personality_toggle = BigMultiParamToggle("following distance", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"], description="Standard is recommended.\n" + "Aggressive follows closer, with firmer gas and braking.\n" + "Relaxed leaves more space.\n" + diff --git a/openpilot/selfdrive/ui/translations/app.pot b/openpilot/selfdrive/ui/translations/app.pot index cdd0af4c86..b73d7de4cd 100644 --- a/openpilot/selfdrive/ui/translations/app.pot +++ b/openpilot/selfdrive/ui/translations/app.pot @@ -716,7 +716,7 @@ msgid "Record and store microphone audio while driving. The audio will be includ msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Driving Personality" +msgid "Following Distance" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py diff --git a/openpilot/selfdrive/ui/translations/app_de.po b/openpilot/selfdrive/ui/translations/app_de.po index 1a3cd51ac4..5bd97360b9 100644 --- a/openpilot/selfdrive/ui/translations/app_de.po +++ b/openpilot/selfdrive/ui/translations/app_de.po @@ -210,8 +210,8 @@ msgid "Download" msgstr "Herunterladen" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Driving Personality" -msgstr "Fahrstil" +msgid "Following Distance" +msgstr "" #: openpilot/system/ui/widgets/network.py msgid "EDIT" diff --git a/openpilot/selfdrive/ui/translations/app_en.po b/openpilot/selfdrive/ui/translations/app_en.po index c45da337eb..9197b33012 100644 --- a/openpilot/selfdrive/ui/translations/app_en.po +++ b/openpilot/selfdrive/ui/translations/app_en.po @@ -210,8 +210,8 @@ msgid "Download" msgstr "Download" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Driving Personality" -msgstr "Driving Personality" +msgid "Following Distance" +msgstr "Following Distance" #: openpilot/system/ui/widgets/network.py msgid "EDIT" diff --git a/openpilot/selfdrive/ui/translations/app_es.po b/openpilot/selfdrive/ui/translations/app_es.po index 55ba293128..528452f2a3 100644 --- a/openpilot/selfdrive/ui/translations/app_es.po +++ b/openpilot/selfdrive/ui/translations/app_es.po @@ -210,8 +210,8 @@ msgid "Download" msgstr "Descargar" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Driving Personality" -msgstr "Estilo de conducción" +msgid "Following Distance" +msgstr "" #: openpilot/system/ui/widgets/network.py msgid "EDIT" diff --git a/openpilot/selfdrive/ui/translations/app_fr.po b/openpilot/selfdrive/ui/translations/app_fr.po index 1e0f370dcd..0e350471c6 100644 --- a/openpilot/selfdrive/ui/translations/app_fr.po +++ b/openpilot/selfdrive/ui/translations/app_fr.po @@ -210,8 +210,8 @@ msgid "Download" msgstr "Télécharger" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Driving Personality" -msgstr "Personnalité de conduite" +msgid "Following Distance" +msgstr "" #: openpilot/system/ui/widgets/network.py msgid "EDIT" diff --git a/openpilot/selfdrive/ui/translations/app_ja.po b/openpilot/selfdrive/ui/translations/app_ja.po index 2a2f76ae15..a54a689e58 100644 --- a/openpilot/selfdrive/ui/translations/app_ja.po +++ b/openpilot/selfdrive/ui/translations/app_ja.po @@ -210,8 +210,8 @@ msgid "Download" msgstr "ダウンロード" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Driving Personality" -msgstr "走行性格" +msgid "Following Distance" +msgstr "" #: openpilot/system/ui/widgets/network.py msgid "EDIT" diff --git a/openpilot/selfdrive/ui/translations/app_ko.po b/openpilot/selfdrive/ui/translations/app_ko.po index 863986c0b6..9ef3e4129b 100644 --- a/openpilot/selfdrive/ui/translations/app_ko.po +++ b/openpilot/selfdrive/ui/translations/app_ko.po @@ -210,8 +210,8 @@ msgid "Download" msgstr "다운로드" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Driving Personality" -msgstr "주행 성향" +msgid "Following Distance" +msgstr "" #: openpilot/system/ui/widgets/network.py msgid "EDIT" diff --git a/openpilot/selfdrive/ui/translations/app_pt-BR.po b/openpilot/selfdrive/ui/translations/app_pt-BR.po index a478ccc931..64e7a04742 100644 --- a/openpilot/selfdrive/ui/translations/app_pt-BR.po +++ b/openpilot/selfdrive/ui/translations/app_pt-BR.po @@ -210,8 +210,8 @@ msgid "Download" msgstr "Baixar" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Driving Personality" -msgstr "Personalidade" +msgid "Following Distance" +msgstr "" #: openpilot/system/ui/widgets/network.py msgid "EDIT" diff --git a/openpilot/selfdrive/ui/translations/app_th.po b/openpilot/selfdrive/ui/translations/app_th.po index c15d859954..a2aadf396d 100644 --- a/openpilot/selfdrive/ui/translations/app_th.po +++ b/openpilot/selfdrive/ui/translations/app_th.po @@ -210,8 +210,8 @@ msgid "Download" msgstr "ดาวน์โหลด" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Driving Personality" -msgstr "บุคลิกภาพในการขับขี่" +msgid "Following Distance" +msgstr "" #: openpilot/system/ui/widgets/network.py msgid "EDIT" diff --git a/openpilot/selfdrive/ui/translations/app_tr.po b/openpilot/selfdrive/ui/translations/app_tr.po index aefbb535de..371137ddc3 100644 --- a/openpilot/selfdrive/ui/translations/app_tr.po +++ b/openpilot/selfdrive/ui/translations/app_tr.po @@ -210,8 +210,8 @@ msgid "Download" msgstr "İndir" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Driving Personality" -msgstr "Sürüş Kişiliği" +msgid "Following Distance" +msgstr "" #: openpilot/system/ui/widgets/network.py msgid "EDIT" diff --git a/openpilot/selfdrive/ui/translations/app_uk.po b/openpilot/selfdrive/ui/translations/app_uk.po index e4717ae462..c4ec2135de 100644 --- a/openpilot/selfdrive/ui/translations/app_uk.po +++ b/openpilot/selfdrive/ui/translations/app_uk.po @@ -210,8 +210,8 @@ msgid "Download" msgstr "Завантажити" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Driving Personality" -msgstr "Стиль водіння" +msgid "Following Distance" +msgstr "" #: openpilot/system/ui/widgets/network.py msgid "EDIT" diff --git a/openpilot/selfdrive/ui/translations/app_zh-CHS.po b/openpilot/selfdrive/ui/translations/app_zh-CHS.po index f1ec22857f..87fe2ef2b6 100644 --- a/openpilot/selfdrive/ui/translations/app_zh-CHS.po +++ b/openpilot/selfdrive/ui/translations/app_zh-CHS.po @@ -210,8 +210,8 @@ msgid "Download" msgstr "下载" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Driving Personality" -msgstr "驾驶风格" +msgid "Following Distance" +msgstr "" #: openpilot/system/ui/widgets/network.py msgid "EDIT" diff --git a/openpilot/selfdrive/ui/translations/app_zh-CHT.po b/openpilot/selfdrive/ui/translations/app_zh-CHT.po index c5e5289984..77498ab86d 100644 --- a/openpilot/selfdrive/ui/translations/app_zh-CHT.po +++ b/openpilot/selfdrive/ui/translations/app_zh-CHT.po @@ -210,8 +210,8 @@ msgid "Download" msgstr "下載" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Driving Personality" -msgstr "駕駛風格" +msgid "Following Distance" +msgstr "" #: openpilot/system/ui/widgets/network.py msgid "EDIT" From 4429ec0c8c659c2ab8f77818773cf345ac623e67 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Thu, 15 Oct 2026 16:04:04 -0700 Subject: [PATCH 247/270] update translations (#39101) --- openpilot/selfdrive/ui/translations/app.pot | 501 +++++++++--------- openpilot/selfdrive/ui/translations/app_de.po | 39 +- openpilot/selfdrive/ui/translations/app_en.po | 39 +- openpilot/selfdrive/ui/translations/app_es.po | 39 +- openpilot/selfdrive/ui/translations/app_fr.po | 39 +- openpilot/selfdrive/ui/translations/app_ja.po | 39 +- openpilot/selfdrive/ui/translations/app_ko.po | 39 +- .../selfdrive/ui/translations/app_pt-BR.po | 39 +- openpilot/selfdrive/ui/translations/app_th.po | 39 +- openpilot/selfdrive/ui/translations/app_tr.po | 39 +- openpilot/selfdrive/ui/translations/app_uk.po | 39 +- .../selfdrive/ui/translations/app_zh-CHS.po | 39 +- .../selfdrive/ui/translations/app_zh-CHT.po | 39 +- 13 files changed, 361 insertions(+), 608 deletions(-) diff --git a/openpilot/selfdrive/ui/translations/app.pot b/openpilot/selfdrive/ui/translations/app.pot index b73d7de4cd..b75382e956 100644 --- a/openpilot/selfdrive/ui/translations/app.pot +++ b/openpilot/selfdrive/ui/translations/app.pot @@ -14,6 +14,10 @@ msgstr "" msgid "Cancel" msgstr "" +#: openpilot/system/ui/widgets/option_dialog.py +msgid "Select" +msgstr "" + #: openpilot/system/ui/widgets/network.py msgid "Advanced" msgstr "" @@ -136,58 +140,24 @@ msgstr "" msgid "Error" msgstr "" -#: openpilot/system/ui/widgets/option_dialog.py -msgid "Select" +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Pair your device to your comma account" msgstr "" -#: openpilot/selfdrive/ui/layouts/settings/device.py -#: openpilot/selfdrive/ui/widgets/setup.py -msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Go to https://connect.comma.ai on your phone" msgstr "" -#: openpilot/selfdrive/ui/widgets/setup.py -msgid "Maximize your training data uploads to improve openpilot's driving models." +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Click \"add new device\" and scan the QR code on the right" msgstr "" -#: openpilot/selfdrive/ui/widgets/setup.py -msgid "Finish Setup" +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Bookmark connect.comma.ai to your home screen to use it like an app" msgstr "" -#: openpilot/selfdrive/ui/widgets/setup.py -msgid "Pair device" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/setup.py -msgid "Open" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/firehose.py -#: openpilot/selfdrive/ui/widgets/setup.py -msgid "Firehose Mode" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/setup.py -msgid "Please connect to Wi-Fi to complete initial pairing" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/offroad_alerts.py -msgid "Close" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/offroad_alerts.py -msgid "Snooze Update" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/offroad_alerts.py -msgid "Acknowledge Excessive Actuation" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/offroad_alerts.py -msgid "Reboot and Update" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/offroad_alerts.py -msgid "No release notes available." +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "QR Code Error" msgstr "" #: openpilot/selfdrive/ui/widgets/ssh_key.py @@ -214,26 +184,6 @@ msgstr "" msgid "No SSH keys found for user '{}'" msgstr "" -#: openpilot/selfdrive/ui/widgets/pairing_dialog.py -msgid "Pair your device to your comma account" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/pairing_dialog.py -msgid "Go to https://connect.comma.ai on your phone" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/pairing_dialog.py -msgid "Click \"add new device\" and scan the QR code on the right" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/pairing_dialog.py -msgid "Bookmark connect.comma.ai to your home screen to use it like an app" -msgstr "" - -#: openpilot/selfdrive/ui/widgets/pairing_dialog.py -msgid "QR Code Error" -msgstr "" - #: openpilot/selfdrive/ui/widgets/prime.py msgid "Upgrade Now" msgstr "" @@ -270,12 +220,54 @@ msgstr "" msgid "Become a comma prime member at connect.comma.ai" msgstr "" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "EXPERIMENTAL MODE ON" +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py +msgid "Close" msgstr "" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py +msgid "Snooze Update" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py +msgid "Acknowledge Excessive Actuation" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py +msgid "Reboot and Update" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py +msgid "No release notes available." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." +msgstr "" + +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Maximize your training data uploads to improve openpilot's driving models." +msgstr "" + +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Finish Setup" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair device" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Open" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/firehose.py +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Firehose Mode" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Please connect to Wi-Fi to complete initial pairing" msgstr "" #: openpilot/selfdrive/ui/layouts/home.py @@ -288,30 +280,6 @@ msgid_plural "{} ALERTS" msgstr[0] "" msgstr[1] "" -#: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Welcome to openpilot" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Agree" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/onboarding.py -msgid "Decline, uninstall openpilot" -msgstr "" - #: openpilot/selfdrive/ui/layouts/sidebar.py msgid "--" msgstr "" @@ -380,6 +348,172 @@ msgstr "" msgid "ERROR" msgstr "" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Welcome to openpilot" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Agree" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms and Conditions in order to use openpilot." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall openpilot" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Enable ADB" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Enable SSH" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "SSH Keys" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Joystick Debug Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Longitudinal Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "openpilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "UI Debug Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Enable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "checking..." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "downloading..." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "finalizing update..." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "never" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "now" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "CHECK" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Are you sure you want to uninstall?" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Uninstall" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Select a branch" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "{} minute ago" +msgid_plural "{} minutes ago" +msgstr[0] "" +msgstr[1] "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "{} hour ago" +msgid_plural "{} hours ago" +msgstr[0] "" +msgstr[1] "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "{} day ago" +msgid_plural "{} days ago" +msgstr[0] "" +msgstr[1] "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Updates are only downloaded while the car is off." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Current Version" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Download" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Install Update" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "INSTALL" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Target Branch" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "SELECT" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "failed to check for update" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "UNINSTALL" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "update available" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "DOWNLOAD" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "up to date, last checked never" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "up to date, last checked {}" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Preview the cabin camera to ensure that driver monitoring has good visibility. (vehicle must be off)" msgstr "" @@ -528,149 +662,6 @@ msgstr "" msgid " Steering torque response calibration is {}% complete." msgstr "" -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "Enable ADB" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "Enable SSH" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "SSH Keys" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "Joystick Debug Mode" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "Longitudinal Maneuver Mode" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "Lateral Maneuver Mode" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/developer.py -msgid "UI Debug Mode" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "checking..." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "downloading..." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "finalizing update..." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "never" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "now" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "CHECK" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "Are you sure you want to uninstall?" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "Uninstall" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "Select a branch" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "{} minute ago" -msgid_plural "{} minutes ago" -msgstr[0] "" -msgstr[1] "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "{} hour ago" -msgid_plural "{} hours ago" -msgstr[0] "" -msgstr[1] "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "{} day ago" -msgid_plural "{} days ago" -msgstr[0] "" -msgstr[1] "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "Updates are only downloaded while the car is off." -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "Current Version" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "Download" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "Install Update" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "INSTALL" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "Target Branch" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "SELECT" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "failed to check for update" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "UNINSTALL" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "update available" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "DOWNLOAD" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "up to date, last checked never" -msgstr "" - -#: openpilot/selfdrive/ui/layouts/settings/software.py -msgid "up to date, last checked {}" -msgstr "" - #: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Device" msgstr "" @@ -695,6 +686,16 @@ msgstr "" msgid "Developer" msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py +#: openpilot/selfdrive/ui/onroad/hud_renderer.py +msgid "km/h" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py +#: openpilot/selfdrive/ui/onroad/hud_renderer.py +msgid "mph" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "When enabled, pressing the accelerator pedal will disengage openpilot." msgstr "" @@ -723,18 +724,10 @@ msgstr "" msgid "Changing this setting will restart openpilot if the car is powered on." msgstr "" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." -msgstr "" - #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable openpilot" msgstr "" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental Mode" -msgstr "" - #: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Disengage on Accelerator Pedal" msgstr "" @@ -760,7 +753,7 @@ msgid "Use Metric System" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." +msgid "Initial Max Speed" msgstr "" #: openpilot/selfdrive/ui/layouts/settings/toggles.py @@ -775,26 +768,10 @@ msgstr "" msgid "Relaxed" msgstr "" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "" - #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "MAX" msgstr "" -#: openpilot/selfdrive/ui/onroad/hud_renderer.py -msgid "km/h" -msgstr "" - -#: openpilot/selfdrive/ui/onroad/hud_renderer.py -msgid "mph" -msgstr "" - -#: openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py -msgid "camera starting" -msgstr "" - #: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "openpilot Unavailable" msgstr "" @@ -815,3 +792,7 @@ msgstr "" msgid "Reboot Device" msgstr "" +#: openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py +msgid "camera starting" +msgstr "" + diff --git a/openpilot/selfdrive/ui/translations/app_de.po b/openpilot/selfdrive/ui/translations/app_de.po index 5bd97360b9..f9076a956c 100644 --- a/openpilot/selfdrive/ui/translations/app_de.po +++ b/openpilot/selfdrive/ui/translations/app_de.po @@ -113,10 +113,6 @@ msgstr "ÄNDERN" msgid "CHECK" msgstr "PRÜFEN" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "CHILL‑MODUS AKTIV" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: openpilot/system/ui/widgets/network.py msgid "CONNECT" @@ -209,10 +205,6 @@ msgstr "Dongle-ID" msgid "Download" msgstr "Herunterladen" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Following Distance" -msgstr "" - #: openpilot/system/ui/widgets/network.py msgid "EDIT" msgstr "BEARBEITEN" @@ -225,12 +217,7 @@ msgstr "FEHLER" msgid "ETH" msgstr "ETH" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "EXPERIMENTAL MODE ON" -msgstr "EXPERIMENTALMODUS AKTIV" - #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Aktivieren" @@ -262,10 +249,6 @@ msgstr "Fahrerüberwachung auch aktivieren, wenn openpilot nicht aktiv ist." msgid "Enable openpilot" msgstr "openpilot aktivieren" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "Den Schalter für die openpilot-Längsregelung (Alpha) aktivieren, um den Experimentalmodus zu erlauben." - #: openpilot/system/ui/widgets/network.py msgid "Enter APN" msgstr "APN eingeben" @@ -290,14 +273,6 @@ msgstr "Geben Sie Ihren GitHub‑Benutzernamen ein" msgid "Error" msgstr "Fehler" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental Mode" -msgstr "Experimentalmodus" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." -msgstr "Der Experimentalmodus ist derzeit auf diesem Fahrzeug nicht verfügbar, da der serienmäßige ACC für die Längsregelung verwendet wird." - #: openpilot/system/ui/widgets/network.py msgid "FORGETTING..." msgstr "WIRD VERGESSEN..." @@ -315,6 +290,10 @@ msgstr "Datenstrom" msgid "Firehose Mode" msgstr "Firehose‑Modus" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" + #: openpilot/system/ui/widgets/network.py msgid "Forget" msgstr "Vergessen" @@ -347,6 +326,10 @@ msgstr "INSTALLIEREN" msgid "IP Address" msgstr "IP‑Adresse" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Update installieren" @@ -721,6 +704,7 @@ msgstr "Update wird finalisiert..." msgid "for \"{}\"" msgstr "für „{}“" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -737,6 +721,7 @@ msgstr "links" msgid "metered" msgstr "getaktet" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -757,10 +742,6 @@ msgstr "openpilot Längsregelung (Alpha)" msgid "openpilot Unavailable" msgstr "openpilot nicht verfügbar" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "Die openpilot‑Längsregelung könnte in einem zukünftigen Update kommen." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." msgstr "openpilot erfordert, dass das Gerät innerhalb von 4° nach links oder rechts und innerhalb von 5° nach oben oder 9° nach unten montiert ist." diff --git a/openpilot/selfdrive/ui/translations/app_en.po b/openpilot/selfdrive/ui/translations/app_en.po index 9197b33012..737e600d30 100644 --- a/openpilot/selfdrive/ui/translations/app_en.po +++ b/openpilot/selfdrive/ui/translations/app_en.po @@ -113,10 +113,6 @@ msgstr "CHANGE" msgid "CHECK" msgstr "CHECK" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "CHILL MODE ON" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: openpilot/system/ui/widgets/network.py msgid "CONNECT" @@ -209,10 +205,6 @@ msgstr "Dongle ID" msgid "Download" msgstr "Download" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Following Distance" -msgstr "Following Distance" - #: openpilot/system/ui/widgets/network.py msgid "EDIT" msgstr "EDIT" @@ -225,12 +217,7 @@ msgstr "ERROR" msgid "ETH" msgstr "ETH" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "EXPERIMENTAL MODE ON" -msgstr "EXPERIMENTAL MODE ON" - #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Enable" @@ -262,10 +249,6 @@ msgstr "Enable driver monitoring even when openpilot is not engaged." msgid "Enable openpilot" msgstr "Enable openpilot" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." - #: openpilot/system/ui/widgets/network.py msgid "Enter APN" msgstr "Enter APN" @@ -290,14 +273,6 @@ msgstr "Enter your GitHub username" msgid "Error" msgstr "Error" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental Mode" -msgstr "Experimental Mode" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." -msgstr "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." - #: openpilot/system/ui/widgets/network.py msgid "FORGETTING..." msgstr "FORGETTING..." @@ -315,6 +290,10 @@ msgstr "Firehose" msgid "Firehose Mode" msgstr "Firehose Mode" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "Following Distance" + #: openpilot/system/ui/widgets/network.py msgid "Forget" msgstr "Forget" @@ -347,6 +326,10 @@ msgstr "INSTALL" msgid "IP Address" msgstr "IP Address" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Install Update" @@ -721,6 +704,7 @@ msgstr "finalizing update..." msgid "for \"{}\"" msgstr "for \"{}\"" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -737,6 +721,7 @@ msgstr "left" msgid "metered" msgstr "metered" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -757,10 +742,6 @@ msgstr "openpilot Longitudinal Control (Alpha)" msgid "openpilot Unavailable" msgstr "openpilot Unavailable" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilot longitudinal control may come in a future update." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." msgstr "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." diff --git a/openpilot/selfdrive/ui/translations/app_es.po b/openpilot/selfdrive/ui/translations/app_es.po index 528452f2a3..2fc8f824c3 100644 --- a/openpilot/selfdrive/ui/translations/app_es.po +++ b/openpilot/selfdrive/ui/translations/app_es.po @@ -113,10 +113,6 @@ msgstr "CAMBIAR" msgid "CHECK" msgstr "COMPROBAR" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "MODO CHILL ACTIVADO" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: openpilot/system/ui/widgets/network.py msgid "CONNECT" @@ -209,10 +205,6 @@ msgstr "ID del dongle" msgid "Download" msgstr "Descargar" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Following Distance" -msgstr "" - #: openpilot/system/ui/widgets/network.py msgid "EDIT" msgstr "EDITAR" @@ -225,12 +217,7 @@ msgstr "FALLO" msgid "ETH" msgstr "ETH" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "EXPERIMENTAL MODE ON" -msgstr "MODO EXPERIMENTAL ACTIVADO" - #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Activar" @@ -262,10 +249,6 @@ msgstr "Activar la supervisión del conductor incluso cuando openpilot no esté msgid "Enable openpilot" msgstr "Activar openpilot" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "Activa el interruptor de control longitudinal de openpilot (alpha) para permitir el modo Experimental." - #: openpilot/system/ui/widgets/network.py msgid "Enter APN" msgstr "Introduce APN" @@ -290,14 +273,6 @@ msgstr "Introduce tu nombre de usuario de GitHub" msgid "Error" msgstr "Fallo" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental Mode" -msgstr "Modo experimental" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." -msgstr "El modo experimental no está disponible actualmente en este coche, ya que se usa el ACC de fábrica para el control longitudinal." - #: openpilot/system/ui/widgets/network.py msgid "FORGETTING..." msgstr "OLVIDAR..." @@ -315,6 +290,10 @@ msgstr "Flujo masivo" msgid "Firehose Mode" msgstr "Modo Firehose" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" + #: openpilot/system/ui/widgets/network.py msgid "Forget" msgstr "Olvidar" @@ -347,6 +326,10 @@ msgstr "INSTALAR" msgid "IP Address" msgstr "Dirección IP" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Instalar actualización" @@ -721,6 +704,7 @@ msgstr "finalizando actualización..." msgid "for \"{}\"" msgstr "para \"{}\"" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -737,6 +721,7 @@ msgstr "izquierda" msgid "metered" msgstr "medido" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -757,10 +742,6 @@ msgstr "Control longitudinal de openpilot (Alpha)" msgid "openpilot Unavailable" msgstr "openpilot no disponible" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "El control longitudinal de openpilot podría llegar en una actualización futura." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." msgstr "openpilot requiere que el dispositivo esté montado dentro de 4° a izquierda o derecha y dentro de 5° hacia arriba o 9° hacia abajo." diff --git a/openpilot/selfdrive/ui/translations/app_fr.po b/openpilot/selfdrive/ui/translations/app_fr.po index 0e350471c6..1ff8422fb3 100644 --- a/openpilot/selfdrive/ui/translations/app_fr.po +++ b/openpilot/selfdrive/ui/translations/app_fr.po @@ -113,10 +113,6 @@ msgstr "CHANGER" msgid "CHECK" msgstr "VÉRIFIER" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "MODE CHILL ACTIVÉ" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: openpilot/system/ui/widgets/network.py msgid "CONNECT" @@ -209,10 +205,6 @@ msgstr "ID du dongle" msgid "Download" msgstr "Télécharger" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Following Distance" -msgstr "" - #: openpilot/system/ui/widgets/network.py msgid "EDIT" msgstr "EDITER" @@ -225,12 +217,7 @@ msgstr "ERREUR" msgid "ETH" msgstr "ETH" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "EXPERIMENTAL MODE ON" -msgstr "MODE EXPÉRIMENTAL ACTIVÉ" - #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Activer" @@ -262,10 +249,6 @@ msgstr "Activer la surveillance du conducteur même lorsque openpilot n'est pas msgid "Enable openpilot" msgstr "Activer openpilot" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "Activez l'option de contrôle longitudinal openpilot (alpha) pour autoriser le mode expérimental." - #: openpilot/system/ui/widgets/network.py msgid "Enter APN" msgstr "Saisir l'APN" @@ -290,14 +273,6 @@ msgstr "Entrez votre nom d'utilisateur GitHub" msgid "Error" msgstr "Erreur" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental Mode" -msgstr "Mode expérimental" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." -msgstr "Le mode expérimental est actuellement indisponible sur cette voiture car l'ACC d'origine est utilisé pour le contrôle longitudinal." - #: openpilot/system/ui/widgets/network.py msgid "FORGETTING..." msgstr "OUBLIER..." @@ -315,6 +290,10 @@ msgstr "Flux continu" msgid "Firehose Mode" msgstr "Mode Firehose" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" + #: openpilot/system/ui/widgets/network.py msgid "Forget" msgstr "Oublier" @@ -347,6 +326,10 @@ msgstr "INSTALLER" msgid "IP Address" msgstr "Adresse IP" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Installer la mise à jour" @@ -721,6 +704,7 @@ msgstr "finalisation de la mise à jour..." msgid "for \"{}\"" msgstr "pour \"{}\"" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -737,6 +721,7 @@ msgstr "gauche" msgid "metered" msgstr "limité" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -757,10 +742,6 @@ msgstr "Contrôle longitudinal openpilot (Alpha)" msgid "openpilot Unavailable" msgstr "openpilot indisponible" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "Le contrôle longitudinal openpilot pourra arriver dans une future mise à jour." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." msgstr "openpilot exige que l'appareil soit monté à moins de 4° à gauche ou à droite et à moins de 5° vers le haut ou 9° vers le bas." diff --git a/openpilot/selfdrive/ui/translations/app_ja.po b/openpilot/selfdrive/ui/translations/app_ja.po index a54a689e58..0028c9068e 100644 --- a/openpilot/selfdrive/ui/translations/app_ja.po +++ b/openpilot/selfdrive/ui/translations/app_ja.po @@ -113,10 +113,6 @@ msgstr "変更" msgid "CHECK" msgstr "確認" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "チルモードON" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: openpilot/system/ui/widgets/network.py msgid "CONNECT" @@ -209,10 +205,6 @@ msgstr "ドングルID" msgid "Download" msgstr "ダウンロード" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Following Distance" -msgstr "" - #: openpilot/system/ui/widgets/network.py msgid "EDIT" msgstr "編集" @@ -225,12 +217,7 @@ msgstr "エラー" msgid "ETH" msgstr "ETH" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "EXPERIMENTAL MODE ON" -msgstr "実験モードON" - #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "有効化" @@ -262,10 +249,6 @@ msgstr "openpilotが未作動でもドライバーモニタリングを有効に msgid "Enable openpilot" msgstr "openpilotを有効化" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "openpilot縦制御(アルファ)のトグルを有効にすると実験モードが使用できます。" - #: openpilot/system/ui/widgets/network.py msgid "Enter APN" msgstr "APNを入力" @@ -290,14 +273,6 @@ msgstr "GitHubユーザー名を入力" msgid "Error" msgstr "エラー" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental Mode" -msgstr "実験モード" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." -msgstr "この車では縦制御に純正ACCを使用するため、現在実験モードは利用できません。" - #: openpilot/system/ui/widgets/network.py msgid "FORGETTING..." msgstr "削除中..." @@ -315,6 +290,10 @@ msgstr "大量配信" msgid "Firehose Mode" msgstr "Firehoseモード" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" + #: openpilot/system/ui/widgets/network.py msgid "Forget" msgstr "削除" @@ -347,6 +326,10 @@ msgstr "インストール" msgid "IP Address" msgstr "IPアドレス" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "アップデートをインストール" @@ -721,6 +704,7 @@ msgstr "アップデートを終了しています..." msgid "for \"{}\"" msgstr "「{}」向け" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -737,6 +721,7 @@ msgstr "左" msgid "metered" msgstr "従量" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -757,10 +742,6 @@ msgstr "openpilot 縦制御(アルファ)" msgid "openpilot Unavailable" msgstr "openpilotは利用できません" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilotの縦制御は将来のアップデートで提供される可能性があります。" - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." msgstr "openpilotでは、デバイスの取り付け角度が左右±4°、上方向5°以内、下方向9°以内である必要があります。" diff --git a/openpilot/selfdrive/ui/translations/app_ko.po b/openpilot/selfdrive/ui/translations/app_ko.po index 9ef3e4129b..de4766a6f0 100644 --- a/openpilot/selfdrive/ui/translations/app_ko.po +++ b/openpilot/selfdrive/ui/translations/app_ko.po @@ -113,10 +113,6 @@ msgstr "변경" msgid "CHECK" msgstr "확인" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "안정적 모드 켜짐" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: openpilot/system/ui/widgets/network.py msgid "CONNECT" @@ -209,10 +205,6 @@ msgstr "동글 ID" msgid "Download" msgstr "다운로드" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Following Distance" -msgstr "" - #: openpilot/system/ui/widgets/network.py msgid "EDIT" msgstr "편집" @@ -225,12 +217,7 @@ msgstr "오류" msgid "ETH" msgstr "ETH" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "EXPERIMENTAL MODE ON" -msgstr "실험 모드 켜짐" - #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "사용" @@ -262,10 +249,6 @@ msgstr "openpilot이 작동 중이 아닐 때도 운전자 모니터링을 사 msgid "Enable openpilot" msgstr "openpilot 사용" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "실험 모드를 사용하려면 openpilot 롱컨 제어(알파) 토글을 켜세요." - #: openpilot/system/ui/widgets/network.py msgid "Enter APN" msgstr "APN 입력" @@ -290,14 +273,6 @@ msgstr "GitHub 사용자 이름 입력" msgid "Error" msgstr "오류" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental Mode" -msgstr "실험 모드" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." -msgstr "이 차량은 롱컨 제어에 순정 ACC를 사용하므로 현재 실험 모드를 사용할 수 없습니다." - #: openpilot/system/ui/widgets/network.py msgid "FORGETTING..." msgstr "삭제 중..." @@ -315,6 +290,10 @@ msgstr "파이어호스" msgid "Firehose Mode" msgstr "파이어호스 모드" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" + #: openpilot/system/ui/widgets/network.py msgid "Forget" msgstr "삭제" @@ -347,6 +326,10 @@ msgstr "설치" msgid "IP Address" msgstr "IP 주소" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "업데이트 설치" @@ -721,6 +704,7 @@ msgstr "업데이트 마무리 중..." msgid "for \"{}\"" msgstr "\"{}\"용" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -737,6 +721,7 @@ msgstr "왼쪽" msgid "metered" msgstr "종량제" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -757,10 +742,6 @@ msgstr "openpilot 롱컨 제어(알파)" msgid "openpilot Unavailable" msgstr "openpilot 사용 불가" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilot 롱컨 제어는 향후 업데이트에서 제공될 수 있습니다." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." msgstr "openpilot은 장치를 좌우 4°, 위쪽 5°, 아래쪽 9° 이내로 장착해야 합니다." diff --git a/openpilot/selfdrive/ui/translations/app_pt-BR.po b/openpilot/selfdrive/ui/translations/app_pt-BR.po index 64e7a04742..422a4b4df3 100644 --- a/openpilot/selfdrive/ui/translations/app_pt-BR.po +++ b/openpilot/selfdrive/ui/translations/app_pt-BR.po @@ -113,10 +113,6 @@ msgstr "ALTERAR" msgid "CHECK" msgstr "VERIFICAR" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "MODO CHILL ATIVO" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: openpilot/system/ui/widgets/network.py msgid "CONNECT" @@ -209,10 +205,6 @@ msgstr "ID do Dongle" msgid "Download" msgstr "Baixar" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Following Distance" -msgstr "" - #: openpilot/system/ui/widgets/network.py msgid "EDIT" msgstr "EDITAR" @@ -225,12 +217,7 @@ msgstr "ERRO" msgid "ETH" msgstr "ETH" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "EXPERIMENTAL MODE ON" -msgstr "MODO EXPERIMENTAL ATIVO" - #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Ativar" @@ -262,10 +249,6 @@ msgstr "Ativar monitoramento do motorista mesmo quando o openpilot não está en msgid "Enable openpilot" msgstr "Ativar openpilot" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "Ative a opção de controle longitudinal do openpilot (alpha) para permitir o Modo Experimental." - #: openpilot/system/ui/widgets/network.py msgid "Enter APN" msgstr "Digite APN" @@ -290,14 +273,6 @@ msgstr "Digite seu nome de usuário do GitHub" msgid "Error" msgstr "Erro" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental Mode" -msgstr "Modo Experimental" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." -msgstr "O Modo Experimental está indisponível neste carro pois o ACC original do carro é usado para controle longitudinal." - #: openpilot/system/ui/widgets/network.py msgid "FORGETTING..." msgstr "ESQUECENDO..." @@ -315,6 +290,10 @@ msgstr "Fluxo contínuo" msgid "Firehose Mode" msgstr "Modo Firehose" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" + #: openpilot/system/ui/widgets/network.py msgid "Forget" msgstr "Esquecer" @@ -347,6 +326,10 @@ msgstr "INSTALAR" msgid "IP Address" msgstr "Endereço IP" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Instalar Atualização" @@ -721,6 +704,7 @@ msgstr "finalizando atualização..." msgid "for \"{}\"" msgstr "para \"{}\"" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -737,6 +721,7 @@ msgstr "à esquerda" msgid "metered" msgstr "limitados" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -757,10 +742,6 @@ msgstr "Controle Longitudinal do openpilot (Alpha)" msgid "openpilot Unavailable" msgstr "openpilot Indisponível" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "o controle longitudinal do openpilot pode vir em uma atualização futura." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." msgstr "o openpilot requer que o dispositivo seja montado dentro de 4° para a esquerda ou direita e dentro de 5° para cima ou 9° para baixo." diff --git a/openpilot/selfdrive/ui/translations/app_th.po b/openpilot/selfdrive/ui/translations/app_th.po index a2aadf396d..42f5458ccf 100644 --- a/openpilot/selfdrive/ui/translations/app_th.po +++ b/openpilot/selfdrive/ui/translations/app_th.po @@ -113,10 +113,6 @@ msgstr "เปลี่ยน" msgid "CHECK" msgstr "ตรวจสอบ" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "เปิดโหมด Chill" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: openpilot/system/ui/widgets/network.py msgid "CONNECT" @@ -209,10 +205,6 @@ msgstr "รหัสดองเกิล" msgid "Download" msgstr "ดาวน์โหลด" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Following Distance" -msgstr "" - #: openpilot/system/ui/widgets/network.py msgid "EDIT" msgstr "แก้ไข" @@ -225,12 +217,7 @@ msgstr "ข้อผิดพลาด" msgid "ETH" msgstr "ผลประโยชน์ทับซ้อน" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "EXPERIMENTAL MODE ON" -msgstr "โหมดทดลองเปิดอยู่" - #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "เปิดใช้งาน" @@ -262,10 +249,6 @@ msgstr "เปิดใช้งานการตรวจสอบไดรเ msgid "Enable openpilot" msgstr "เปิดใช้งานโอเพ่นไพลอต" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "เปิดใช้งานการสลับการควบคุมตามยาวของ openpilot (อัลฟา) เพื่ออนุญาตโหมดการทดลอง" - #: openpilot/system/ui/widgets/network.py msgid "Enter APN" msgstr "ป้อน APN" @@ -290,14 +273,6 @@ msgstr "ป้อนชื่อผู้ใช้ GitHub ของคุณ" msgid "Error" msgstr "ข้อผิดพลาด" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental Mode" -msgstr "โหมดทดลอง" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." -msgstr "ขณะนี้โหมดทดลองไม่สามารถใช้งานได้บนรถคันนี้ เนื่องจาก ACC ในสต็อกของรถใช้สำหรับการควบคุมตามยาว" - #: openpilot/system/ui/widgets/network.py msgid "FORGETTING..." msgstr "กำลังลืม..." @@ -315,6 +290,10 @@ msgstr "สายดับเพลิง" msgid "Firehose Mode" msgstr "โหมดสายดับเพลิง" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" + #: openpilot/system/ui/widgets/network.py msgid "Forget" msgstr "ลืม" @@ -347,6 +326,10 @@ msgstr "ติดตั้ง" msgid "IP Address" msgstr "ที่อยู่ IP" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "ติดตั้งอัปเดต" @@ -721,6 +704,7 @@ msgstr "กำลังสรุปการอัปเดต..." msgid "for \"{}\"" msgstr "สำหรับ \"{}\"" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "กม./ชม" @@ -737,6 +721,7 @@ msgstr "ซ้าย" msgid "metered" msgstr "คิดค่าบริการตามปริมาณ" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "ไมล์/ชม." @@ -757,10 +742,6 @@ msgstr "การควบคุมตามยาวของ openpilot (อั msgid "openpilot Unavailable" msgstr "openpilot ไม่พร้อมใช้งาน" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "ระบบควบคุมตามยาวของ openpilot อาจมาในการอัปเดตครั้งถัดไป" - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." msgstr "openpilot ต้องติดตั้งอุปกรณ์ให้อยู่ในช่วงเอียงซ้ายหรือขวาไม่เกิน 4° และเอียงขึ้นไม่เกิน 5° หรือเอียงลงไม่เกิน 9°" diff --git a/openpilot/selfdrive/ui/translations/app_tr.po b/openpilot/selfdrive/ui/translations/app_tr.po index 371137ddc3..26dc9e4317 100644 --- a/openpilot/selfdrive/ui/translations/app_tr.po +++ b/openpilot/selfdrive/ui/translations/app_tr.po @@ -113,10 +113,6 @@ msgstr "DEĞİŞTİR" msgid "CHECK" msgstr "KONTROL ET" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "CHILL MODU AÇIK" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: openpilot/system/ui/widgets/network.py msgid "CONNECT" @@ -209,10 +205,6 @@ msgstr "Dongle kimliği" msgid "Download" msgstr "İndir" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Following Distance" -msgstr "" - #: openpilot/system/ui/widgets/network.py msgid "EDIT" msgstr "DÜZENLE" @@ -225,12 +217,7 @@ msgstr "HATA" msgid "ETH" msgstr "ETH" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "EXPERIMENTAL MODE ON" -msgstr "DENEYSEL MOD AÇIK" - #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Etkinleştir" @@ -262,10 +249,6 @@ msgstr "openpilot devrede değilken bile sürücü izlemesini etkinleştir." msgid "Enable openpilot" msgstr "openpilot'u etkinleştir" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "Deneysel modu etkinleştirmek için openpilot boylamsal kontrolünü (alfa) açın." - #: openpilot/system/ui/widgets/network.py msgid "Enter APN" msgstr "APN girin" @@ -290,14 +273,6 @@ msgstr "GitHub kullanıcı adınızı girin" msgid "Error" msgstr "Hata" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental Mode" -msgstr "Deneysel Mod" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." -msgstr "Bu araçta boylamsal kontrol için stok ACC kullanıldığından şu anda Deneysel mod kullanılamıyor." - #: openpilot/system/ui/widgets/network.py msgid "FORGETTING..." msgstr "UNUTULUYOR..." @@ -315,6 +290,10 @@ msgstr "Yoğun veri akışı" msgid "Firehose Mode" msgstr "Firehose Modu" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" + #: openpilot/system/ui/widgets/network.py msgid "Forget" msgstr "Unut" @@ -347,6 +326,10 @@ msgstr "YÜKLE" msgid "IP Address" msgstr "IP Adresi" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Güncellemeyi Yükle" @@ -721,6 +704,7 @@ msgstr "güncelleme tamamlanıyor..." msgid "for \"{}\"" msgstr "\"{}\" için" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -737,6 +721,7 @@ msgstr "sol" msgid "metered" msgstr "ölçülü" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -757,10 +742,6 @@ msgstr "openpilot Boylamsal Kontrol (Alfa)" msgid "openpilot Unavailable" msgstr "openpilot Kullanılamıyor" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilot boylamsal kontrolü gelecekteki bir güncellemede gelebilir." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." msgstr "openpilot, cihazın sağa/sola 4° ve yukarı 5° veya aşağı 9° içinde monte edilmesini gerektirir." diff --git a/openpilot/selfdrive/ui/translations/app_uk.po b/openpilot/selfdrive/ui/translations/app_uk.po index c4ec2135de..7bda77f5a9 100644 --- a/openpilot/selfdrive/ui/translations/app_uk.po +++ b/openpilot/selfdrive/ui/translations/app_uk.po @@ -113,10 +113,6 @@ msgstr "ЗМІНИТИ" msgid "CHECK" msgstr "ПЕРЕВІРИТИ" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "СПОКІЙНИЙ РЕЖИМ" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: openpilot/system/ui/widgets/network.py msgid "CONNECT" @@ -209,10 +205,6 @@ msgstr "ID ключа" msgid "Download" msgstr "Завантажити" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Following Distance" -msgstr "" - #: openpilot/system/ui/widgets/network.py msgid "EDIT" msgstr "РЕДАГ." @@ -225,12 +217,7 @@ msgstr "ПОМИЛКА" msgid "ETH" msgstr "ETH" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "EXPERIMENTAL MODE ON" -msgstr "ЕКСПЕРИМЕНТ. РЕЖИМ" - #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Увімкнути" @@ -262,10 +249,6 @@ msgstr "Увімкнути моніторинг водія, навіть кол msgid "Enable openpilot" msgstr "Увімкнути openpilot" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "Увімкніть перемикач поздовжнього керування openpilot (альфа), щоб увімкнути експериментальний режим." - #: openpilot/system/ui/widgets/network.py msgid "Enter APN" msgstr "Введіть APN" @@ -290,14 +273,6 @@ msgstr "Введіть ваш логін GitHub" msgid "Error" msgstr "Помилка" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental Mode" -msgstr "Експериментальний режим" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." -msgstr "Експериментальний режим наразі недоступний для цього автомобіля, оскільки для поздовжнього керування використовується штатний адаптивний круїз-контроль (ACC)." - #: openpilot/system/ui/widgets/network.py msgid "FORGETTING..." msgstr "ЗАБУВАЮ..." @@ -315,6 +290,10 @@ msgstr "Злива" msgid "Firehose Mode" msgstr "Режим зливи" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" + #: openpilot/system/ui/widgets/network.py msgid "Forget" msgstr "Забути" @@ -347,6 +326,10 @@ msgstr "ВСТАНОВ." msgid "IP Address" msgstr "IP-адреса" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Встановити оновлення" @@ -721,6 +704,7 @@ msgstr "завершую..." msgid "for \"{}\"" msgstr "для \"{}\"" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "км/год" @@ -737,6 +721,7 @@ msgstr "вліво" msgid "metered" msgstr "обмеж." +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "миль/год" @@ -757,10 +742,6 @@ msgstr "Поздовжнє керування openpilot (Альфа)" msgid "openpilot Unavailable" msgstr "openpilot Недоступний" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "Поздовжнє керування openpilot може з'явитися в майбутньому оновленні." - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." msgstr "Для роботи openpilot потрібно, щоб пристрій був встановлений з нахилом не більше 4° вліво або вправо та не більше 5° вгору або 9° вниз. openpilot постійно калібрується, тому скидання калібрування потрібне рідко." diff --git a/openpilot/selfdrive/ui/translations/app_zh-CHS.po b/openpilot/selfdrive/ui/translations/app_zh-CHS.po index 87fe2ef2b6..21fe9493fb 100644 --- a/openpilot/selfdrive/ui/translations/app_zh-CHS.po +++ b/openpilot/selfdrive/ui/translations/app_zh-CHS.po @@ -113,10 +113,6 @@ msgstr "更改" msgid "CHECK" msgstr "检查" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "安稳模式已开启" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: openpilot/system/ui/widgets/network.py msgid "CONNECT" @@ -209,10 +205,6 @@ msgstr "设备 ID" msgid "Download" msgstr "下载" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Following Distance" -msgstr "" - #: openpilot/system/ui/widgets/network.py msgid "EDIT" msgstr "编辑" @@ -225,12 +217,7 @@ msgstr "错误" msgid "ETH" msgstr "ETH" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "EXPERIMENTAL MODE ON" -msgstr "实验模式已开启" - #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "启用" @@ -262,10 +249,6 @@ msgstr "即使未启用 openpilot 也启用驾驶员监控。" msgid "Enable openpilot" msgstr "启用 openpilot" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "启用 openpilot 纵向控制(alpha)开关,以使用实验模式。" - #: openpilot/system/ui/widgets/network.py msgid "Enter APN" msgstr "输入 APN" @@ -290,14 +273,6 @@ msgstr "输入您的 GitHub 用户名" msgid "Error" msgstr "错误" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental Mode" -msgstr "实验模式" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." -msgstr "此车型当前无法使用实验模式,因为纵向控制使用的是原厂 ACC。" - #: openpilot/system/ui/widgets/network.py msgid "FORGETTING..." msgstr "正在遗忘..." @@ -315,6 +290,10 @@ msgstr "数据洪流" msgid "Firehose Mode" msgstr "Firehose 模式" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" + #: openpilot/system/ui/widgets/network.py msgid "Forget" msgstr "忘记" @@ -347,6 +326,10 @@ msgstr "安装" msgid "IP Address" msgstr "IP 地址" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "安装更新" @@ -721,6 +704,7 @@ msgstr "正在完成更新..." msgid "for \"{}\"" msgstr "用于“{}”" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -737,6 +721,7 @@ msgstr "左" msgid "metered" msgstr "计量" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -757,10 +742,6 @@ msgstr "openpilot 纵向控制(Alpha)" msgid "openpilot Unavailable" msgstr "openpilot 无法使用" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilot 纵向控制可能会在未来更新中提供。" - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." msgstr "openpilot 要求设备安装在左右 4°、上 5° 或下 9° 以内。" diff --git a/openpilot/selfdrive/ui/translations/app_zh-CHT.po b/openpilot/selfdrive/ui/translations/app_zh-CHT.po index 77498ab86d..39df299a51 100644 --- a/openpilot/selfdrive/ui/translations/app_zh-CHT.po +++ b/openpilot/selfdrive/ui/translations/app_zh-CHT.po @@ -113,10 +113,6 @@ msgstr "變更" msgid "CHECK" msgstr "檢查" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "CHILL MODE ON" -msgstr "安穩模式已開啟" - #: openpilot/selfdrive/ui/layouts/sidebar.py #: openpilot/system/ui/widgets/network.py msgid "CONNECT" @@ -209,10 +205,6 @@ msgstr "裝置 ID" msgid "Download" msgstr "下載" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Following Distance" -msgstr "" - #: openpilot/system/ui/widgets/network.py msgid "EDIT" msgstr "編輯" @@ -225,12 +217,7 @@ msgstr "錯誤" msgid "ETH" msgstr "ETH" -#: openpilot/selfdrive/ui/widgets/exp_mode_button.py -msgid "EXPERIMENTAL MODE ON" -msgstr "實驗模式已開啟" - #: openpilot/selfdrive/ui/layouts/settings/developer.py -#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "啟用" @@ -262,10 +249,6 @@ msgstr "即使未啟動 openpilot 亦啟用駕駛監控。" msgid "Enable openpilot" msgstr "啟用 openpilot" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Enable the openpilot longitudinal control (alpha) toggle to allow Experimental mode." -msgstr "啟用 openpilot 縱向控制(alpha)切換,以使用實驗模式。" - #: openpilot/system/ui/widgets/network.py msgid "Enter APN" msgstr "輸入 APN" @@ -290,14 +273,6 @@ msgstr "輸入您的 GitHub 使用者名稱" msgid "Error" msgstr "錯誤" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental Mode" -msgstr "實驗模式" - -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." -msgstr "此車款目前無法使用實驗模式,因為縱向控制使用的是原廠 ACC。" - #: openpilot/system/ui/widgets/network.py msgid "FORGETTING..." msgstr "正在遺忘..." @@ -315,6 +290,10 @@ msgstr "資料洪流" msgid "Firehose Mode" msgstr "Firehose 模式" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Following Distance" +msgstr "" + #: openpilot/system/ui/widgets/network.py msgid "Forget" msgstr "忘記" @@ -347,6 +326,10 @@ msgstr "安裝" msgid "IP Address" msgstr "IP 位址" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Initial Max Speed" +msgstr "" + #: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "安裝更新" @@ -721,6 +704,7 @@ msgstr "正在完成更新..." msgid "for \"{}\"" msgstr "適用於「{}」" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" @@ -737,6 +721,7 @@ msgstr "左" msgid "metered" msgstr "計量" +#: openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py #: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" @@ -757,10 +742,6 @@ msgstr "openpilot 縱向控制(Alpha)" msgid "openpilot Unavailable" msgstr "openpilot 無法使用" -#: openpilot/selfdrive/ui/layouts/settings/toggles.py -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilot 縱向控制可能於未來更新提供。" - #: openpilot/selfdrive/ui/layouts/settings/device.py msgid "openpilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." msgstr "openpilot 要求裝置安裝在左右 4°、上 5° 或下 9° 以內。" From cd3d5b0057e1de05dba55829e20ef2f982789b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Thu, 15 Oct 2026 16:52:13 -0700 Subject: [PATCH 248/270] Update RELEASES.md for version 0.11.3 changes Added details about the new auto-speed feature in version 0.11.3, including its capabilities and transition from beta mode. --- RELEASES.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/RELEASES.md b/RELEASES.md index d0bc1e1d21..ffe9774195 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,6 +1,12 @@ Version 0.11.3 (2026-XX-XX) ======================== - +* Auto-speed + * On supported cars openpilot will decide what speed to drive intelligently based on the scene, including: + * stopping at red lights, and going at green lights + * slowing down for curves + * adjusting speed based on surrounding traffic + * This behavior was previously available in openpilot's beta mode called "experimental mode", but now is now out of beta and always enabled + * With openpilot deciding which speed to drive the car's cruise speed is treated as a maximum speed openpilot will not exceed Version 0.11.2 (2026-10-14) ======================= From f5bcd10a2adae936a8e5cc2c43e90fd52bb4a1ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Thu, 15 Oct 2026 16:54:10 -0700 Subject: [PATCH 249/270] Fix typo in Autospeed feature name --- RELEASES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASES.md b/RELEASES.md index ffe9774195..4b1a78e8a9 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,6 +1,6 @@ Version 0.11.3 (2026-XX-XX) ======================== -* Auto-speed +* Autospeed * On supported cars openpilot will decide what speed to drive intelligently based on the scene, including: * stopping at red lights, and going at green lights * slowing down for curves From fcf4b0d3957ac7df043c19fa7eed644c3ea79ed4 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Thu, 15 Oct 2026 20:51:41 -0700 Subject: [PATCH 250/270] Initial cruise set speed scroller ui (#39098) * Revert " A New Dawn" This reverts commit f50108b4af28c04425b34290f4f1c2b882bd06f0. * Revert "Add configurable initial max speed (#39081)" This reverts commit 24c26487f42b1d92cf96e3e23d686208e35e74e3. * add back some stuff and new ui * exp mode removal again * restore tici * rm exp mode and add new designs, fix color bug for tici * dedup * more dedup * round values when toggling between metric! * nick design tm * inlining * simplification * fix * cmts * clr * clr * remove taparea * rm * rm * clean up --- .../layouts/settings/initial_cruise_speed.py | 88 +++++----- .../selfdrive/ui/layouts/settings/toggles.py | 2 +- .../layouts/settings/initial_cruise_speed.py | 160 ++++++++++++++++++ .../ui/mici/layouts/settings/toggles.py | 36 +--- openpilot/selfdrive/ui/ui_state.py | 2 + openpilot/system/ui/lib/scroll_panel2.py | 4 + 6 files changed, 223 insertions(+), 69 deletions(-) create mode 100644 openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py diff --git a/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py b/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py index fb30e2c524..42ece18eb5 100644 --- a/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py +++ b/openpilot/selfdrive/ui/layouts/settings/initial_cruise_speed.py @@ -1,29 +1,49 @@ -import math import time import pyray as rl from openpilot.common.constants import CV -from openpilot.common.params import Params from openpilot.selfdrive.car.cruise import V_CRUISE_INITIAL, V_CRUISE_MAX +from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import FontWeight, TextAlignment from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets.button import Button, ButtonStyle from openpilot.system.ui.widgets.label import gui_label -from openpilot.system.ui.widgets.list_view import ItemAction +from openpilot.system.ui.widgets.list_view import ItemAction, BUTTON_HEIGHT, BUTTON_BORDER_RADIUS + +SPEED_STEP = 5 +STEP_BUTTON_WIDTH = 160 +VALUE_FONT_SIZE = 50 +REPEAT_DELAY = 0.5 # seconds held before repeating +REPEAT_INTERVAL = 0.2 # seconds + + +def _speed_conversion() -> float: + return 1 if ui_state.is_metric else CV.KPH_TO_MPH + + +def get_speed_options() -> tuple[list[int], int]: + """Initial cruise speeds in display units, and the index of the nearest to the saved one""" + conversion = _speed_conversion() + options = list(range(round(V_CRUISE_INITIAL * conversion), round(V_CRUISE_MAX * conversion) + 1, SPEED_STEP)) + idx = round((ui_state.initial_cruise_speed * conversion - options[0]) / SPEED_STEP) + return options, max(0, min(len(options) - 1, idx)) + + +def set_initial_cruise_speed(speed: int) -> None: + """Save an initial cruise speed in display units""" + ui_state.params.put("InitialCruiseSpeedKph", round(speed / _speed_conversion()), block=True) + ui_state.update_params() class RepeatButton(Button): def __init__(self, text, callback, font_size): - super().__init__(text, callback, font_size=font_size, button_style=ButtonStyle.PILL, border_radius=16, text_padding=0) + super().__init__(text, callback, font_size=font_size, button_style=ButtonStyle.PILL, border_radius=BUTTON_BORDER_RADIUS, text_padding=0) self._repeat_at: float | None = None self._repeated = False - def cancel_repeat(self): - self._repeat_at = None - self._repeated = True - def _handle_mouse_press(self, mouse_pos): - self._repeat_at = time.monotonic() + 0.5 + super()._handle_mouse_press(mouse_pos) + self._repeat_at = time.monotonic() + REPEAT_DELAY self._repeated = False def _handle_mouse_release(self, mouse_pos): @@ -32,49 +52,39 @@ class RepeatButton(Button): def _process_mouse_events(self): super()._process_mouse_events() + # step repeatedly while held. once it has repeated, the release doesn't step again if not self.is_pressed or not self._touch_valid(): - self.cancel_repeat() + self._repeat_at = None elif self._repeat_at is not None and time.monotonic() >= self._repeat_at: self._repeated = True - self._repeat_at = time.monotonic() + 0.2 + self._repeat_at = time.monotonic() + REPEAT_INTERVAL if self._click_callback: self._click_callback() class InitialCruiseSpeedControl(ItemAction): - def __init__(self, compact: bool = False): - super().__init__(width=322 if compact else 580) - self._params = Params() - self._button_size = 64 if compact else 100 - self._font_size = 36 if compact else 50 + def __init__(self): + super().__init__(width=580) + self._options, self._selected_idx = get_speed_options() - self._decrease = self._child(RepeatButton("-", lambda: self._adjust_speed(-5), self._font_size)) - self._increase = self._child(RepeatButton("+", lambda: self._adjust_speed(5), self._font_size)) - self._decrease.set_enabled(lambda: self.enabled and self._value > self._minimum) - self._increase.set_enabled(lambda: self.enabled and self._value < self._maximum) + self._decrease = self._child(RepeatButton("-", lambda: self._adjust_speed(-1), VALUE_FONT_SIZE)) + self._increase = self._child(RepeatButton("+", lambda: self._adjust_speed(1), VALUE_FONT_SIZE)) + self._decrease.set_enabled(lambda: self.enabled and self._selected_idx > 0) + self._increase.set_enabled(lambda: self.enabled and self._selected_idx < len(self._options) - 1) self._decrease.set_touch_valid_callback(self._touch_valid) self._increase.set_touch_valid_callback(self._touch_valid) - self.refresh() - def refresh(self, is_metric: bool | None = None): - for button in (self._decrease, self._increase): - button.cancel_repeat() - self._metric = self._params.get_bool("IsMetric") if is_metric is None else is_metric - self._conversion = 1 if self._metric else CV.KPH_TO_MPH - self._minimum = round(V_CRUISE_INITIAL * self._conversion) - self._maximum = round(V_CRUISE_MAX * self._conversion) - self._value = max(self._minimum, round(self._params.get("InitialCruiseSpeedKph", return_default=True) * self._conversion)) + def refresh(self): + self._options, self._selected_idx = get_speed_options() - def _adjust_speed(self, delta: int): - round_func = math.floor if delta > 0 else math.ceil - self._value = round_func(self._value / 5) * 5 + delta - self._params.put("InitialCruiseSpeedKph", round(self._value / self._conversion), block=True) + def _adjust_speed(self, step: int): + self._selected_idx += step + set_initial_cruise_speed(self._options[self._selected_idx]) def _render(self, rect): - size = self._button_size - y = rect.y + (rect.height - size) / 2 - self._decrease.render(rl.Rectangle(rect.x, y, size, size)) - self._increase.render(rl.Rectangle(rect.x + rect.width - size, y, size, size)) - text = f"{self._value} " + (tr("km/h") if self._metric else tr("mph")) - gui_label(rl.Rectangle(rect.x + size, y, rect.width - 2 * size, size), text, self._font_size, + y = rect.y + (rect.height - BUTTON_HEIGHT) / 2 + self._decrease.render(rl.Rectangle(rect.x, y, STEP_BUTTON_WIDTH, BUTTON_HEIGHT)) + self._increase.render(rl.Rectangle(rect.x + rect.width - STEP_BUTTON_WIDTH, y, STEP_BUTTON_WIDTH, BUTTON_HEIGHT)) + text = f"{self._options[self._selected_idx]} " + (tr("km/h") if ui_state.is_metric else tr("mph")) + gui_label(rl.Rectangle(rect.x + STEP_BUTTON_WIDTH, y, rect.width - 2 * STEP_BUTTON_WIDTH, BUTTON_HEIGHT), text, VALUE_FONT_SIZE, color=rl.WHITE if self.enabled else rl.GRAY, font_weight=FontWeight.MEDIUM, alignment=TextAlignment.CENTER) diff --git a/openpilot/selfdrive/ui/layouts/settings/toggles.py b/openpilot/selfdrive/ui/layouts/settings/toggles.py index 4d6c2812fc..b7d864bc91 100644 --- a/openpilot/selfdrive/ui/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/layouts/settings/toggles.py @@ -174,7 +174,7 @@ class TogglesLayout(Widget): def _toggle_callback(self, state: bool, param: str): self._params.put_bool(param, state, block=True) if param == "IsMetric": - self._initial_cruise_speed.action_item.refresh() + self._update_toggles() if self._toggle_defs[param][3]: self._params.put_bool("OnroadCycleRequested", True, block=True) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py b/openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py new file mode 100644 index 0000000000..e366738893 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/layouts/settings/initial_cruise_speed.py @@ -0,0 +1,160 @@ +import math +import pyray as rl +from collections.abc import Callable + +from openpilot.selfdrive.ui.layouts.settings.initial_cruise_speed import get_speed_options, set_initial_cruise_speed +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialogBase +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, FONT_SCALE +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.scroll_panel2 import AUTO_SCROLL_TC, GuiScrollPanel2, ScrollState +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel + +ITEM_DRAG_DISTANCE = 120 # px of drag per value +ITEM_GAP = 20 # px between adjacent values +VALUE_CENTER_Y = 102 +VISIBLE_VALUES = 3 # on each side of center value + +# value styles +CENTER_FONT_SIZE = 120 +CENTER_LETTER_SPACING = -0.03 +SIDE_FONT_SIZE = 96 +SIDE_LETTER_SPACING = -0.01 + + +def lerp(start: float, end: float, t: float) -> float: + return start + (end - start) * t + + +class SpeedScroller(Widget): + def __init__(self, change_callback: Callable[[], None]): + super().__init__() + self._change_callback = change_callback + + self._center_font = gui_app.font(FontWeight.BOLD) + self._side_font = gui_app.font(FontWeight.ROMAN) + + self._scroll_panel = GuiScrollPanel2(horizontal=True) + self._scroll_panel.set_enabled(lambda: self.enabled) + self.set_touch_valid_callback(self._scroll_panel.is_touch_valid) + self.set_click_callback(self._on_tap) + + self._options: list[int] = [] + self._center_widths: list[float] = [] + self._side_widths: list[float] = [] + self._locked_idx: int | None = None # value the scroller is easing onto, None while the user drags + + def show_event(self): + super().show_event() + self._options, self._locked_idx = get_speed_options() + self._center_widths = [measure_text_cached(self._center_font, str(option), CENTER_FONT_SIZE, + CENTER_LETTER_SPACING * CENTER_FONT_SIZE * FONT_SCALE).x for option in self._options] + self._side_widths = [measure_text_cached(self._side_font, str(option), SIDE_FONT_SIZE, + SIDE_LETTER_SPACING * SIDE_FONT_SIZE * FONT_SCALE).x for option in self._options] + + self._scroll_panel.set_offset(-self._locked_idx * ITEM_DRAG_DISTANCE) + + def _clamp_index(self, idx: int) -> int: + return max(0, min(len(self._options) - 1, idx)) + + def _lock(self, idx: int): + self._locked_idx = self._clamp_index(idx) + set_initial_cruise_speed(self._options[self._locked_idx]) + self._change_callback() + + def _update_state(self): + super()._update_state() + + # when the finger releases, lock the value where the scroll will stop + if self._scroll_panel.state == ScrollState.MANUAL_SCROLL: + self._locked_idx = None + elif self._locked_idx is None and self._scroll_panel.state != ScrollState.PRESSED: + projected_offset = self._scroll_panel.get_offset() + self._scroll_panel.velocity * AUTO_SCROLL_TC + self._lock(round(-projected_offset / ITEM_DRAG_DISTANCE)) + + snap_target = -self._locked_idx * ITEM_DRAG_DISTANCE if self._locked_idx is not None else None + content_size = (len(self._options) - 1) * ITEM_DRAG_DISTANCE + self._rect.width + self._scroll_panel.update(self._rect, content_size, snap_target=snap_target) + + def _on_tap(self): + if self._locked_idx is None: + return + + # a tap on the left or the right moves one value + tap_x = gui_app.last_mouse_event.pos.x + center_x = self._rect.x + self._rect.width / 2 + tap_zone = self._center_widths[self._locked_idx] / 2 + ITEM_GAP / 2 + if tap_x > center_x + tap_zone: + self._lock(self._locked_idx + 1) + elif tap_x < center_x - tap_zone: + self._lock(self._locked_idx - 1) + + def _render(self, _): + center_x = self._rect.x + self._rect.width / 2 + current = -self._scroll_panel.get_offset() / ITEM_DRAG_DISTANCE + + # the style of each value changes from the center style to the side style over a distance of one value + blends = [min(abs(idx - current), 1.0) for idx in range(len(self._options))] + widths = [lerp(center, side, t) for center, side, t in zip(self._center_widths, self._side_widths, blends, strict=True)] + + # lay values out with a constant gap, then shift so the scroll position sits at the center + centers = [0.0] + for idx in range(1, len(widths)): + centers.append(centers[-1] + widths[idx - 1] / 2 + ITEM_GAP + widths[idx] / 2) + left_idx = max(0, min(len(widths) - 2, math.floor(current))) + shift = center_x - lerp(centers[left_idx], centers[left_idx + 1], current - left_idx) + + # only the values near the center can be on the screen. the nearest value uses the bold font + closest_idx = self._clamp_index(round(current)) + for idx in range(max(0, closest_idx - VISIBLE_VALUES), min(len(self._options), closest_idx + VISIBLE_VALUES + 1)): + t = blends[idx] + font_size = lerp(CENTER_FONT_SIZE, SIDE_FONT_SIZE, t) + spacing = lerp(CENTER_LETTER_SPACING, SIDE_LETTER_SPACING, t) * font_size * FONT_SCALE + color = rl.Color(255, 255, 255, int(255 * lerp(0.9, 0.45, t))) + # use the width of the font that draws the value. this keeps the value at its center when the font changes + if idx == closest_idx: + font, width = self._center_font, self._center_widths[idx] * font_size / CENTER_FONT_SIZE + else: + font, width = self._side_font, self._side_widths[idx] * font_size / SIDE_FONT_SIZE + position = rl.Vector2(centers[idx] + shift - width / 2, self._rect.y + VALUE_CENTER_Y - font_size * FONT_SCALE / 2) + rl.draw_text_ex(font, str(self._options[idx]), position, font_size, spacing, color) + + # gradient on left and right + rl.draw_rectangle_gradient_h(int(self._rect.x), int(self._rect.y), 100, int(self._rect.height), rl.BLACK, rl.BLANK) + rl.draw_rectangle_gradient_h(int(self._rect.x + self._rect.width - 100), int(self._rect.y), 100, int(self._rect.height), rl.BLANK, rl.BLACK) + + +class InitialCruiseSpeedPicker(BigDialogBase): + def __init__(self, change_callback: Callable[[], None]): + super().__init__() + self._unit_label = self._child(UnifiedLabel("", 36, FontWeight.BOLD, LABEL_COLOR, alignment=TextAlignment.CENTER, letter_spacing=-0.03)) + self._speed_scroller = self._child(SpeedScroller(change_callback)) + self._speed_scroller.set_enabled(lambda: self.enabled and not self.is_dismissing) + + def show_event(self): + super().show_event() + self._unit_label.set_text(tr("KPH") if ui_state.is_metric else tr("MPH")) + + def _render(self, _): + self._speed_scroller.render(self._rect) + self._unit_label.render(rl.Rectangle(self._rect.x, self._rect.y + 166, self._rect.width, self._rect.height - 166)) + + +class InitialCruiseSpeedButton(BigButton): + def __init__(self): + super().__init__("initial max speed", " ", description="Max speed when engaging openpilot.\n" + + "If you are already driving faster, your current speed is used.\n" + + "Resume restores the previous max speed.") + self._picker = InitialCruiseSpeedPicker(self.refresh) + self.set_click_callback(lambda: gui_app.push_widget(self._picker)) + self.refresh() + + def _get_label_font_size(self): + return 42 + + def refresh(self): + options, idx = get_speed_options() + self.set_value(f"{options[idx]} " + (tr("kph") if ui_state.is_metric else tr("mph"))) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index a491e2ac37..e1b95bf472 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -1,41 +1,15 @@ from openpilot.cereal import log from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigParamControl, BigMultiParamToggle +from openpilot.selfdrive.ui.mici.widgets.button import BigToggle, BigParamControl, BigMultiParamToggle from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.selfdrive.ui.layouts.settings.initial_cruise_speed import InitialCruiseSpeedControl +from openpilot.selfdrive.ui.mici.layouts.settings.initial_cruise_speed import InitialCruiseSpeedButton PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants -class InitialCruiseSpeedButton(BigButton): - def __init__(self): - super().__init__("initial max speed", " ", description="Max speed when engaging openpilot.\n" + - "If you are already driving faster, your current speed is used.\n" + - "Resume restores the previous max speed.") - self._control = self._child(InitialCruiseSpeedControl(compact=True)) - self._control.set_enabled(lambda: self.enabled) - self._control.set_touch_valid_callback(self._touch_valid) - self._show_description = self._long_press_callback - self.set_long_press_callback(self._on_long_press) - - def _on_long_press(self): - # a hold on the speed control repeats a step - if not self._control.is_pressed and self._show_description is not None: - self._show_description() - - def _get_label_font_size(self): - return 36 - - def _draw_subtitle(self, rect): - self._control.render(rect) - - def refresh(self, is_metric: bool | None = None): - self._control.refresh(is_metric) - - class TogglesLayoutMici(NavScroller): def __init__(self): super().__init__() @@ -47,7 +21,7 @@ class TogglesLayoutMici(NavScroller): "Use the steering wheel distance button on supported cars.") self._initial_cruise_speed = InitialCruiseSpeedButton() self._initial_cruise_speed.set_visible(lambda: ui_state.CP is not None and ui_state.has_longitudinal_control and not ui_state.CP.pcmCruise) - is_metric_toggle = BigParamControl("use metric units", "IsMetric", toggle_callback=self._initial_cruise_speed.refresh) + is_metric_toggle = BigToggle("use metric units", initial_state=ui_state.params.get_bool("IsMetric"), toggle_callback=self._on_metric_toggle) ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled", description="Warn when you drift across a detected lane line.\n" + "Only above 31 mph (50 km/h), with no turn signal.") @@ -107,6 +81,10 @@ class TogglesLayoutMici(NavScroller): super().show_event() self._update_toggles() + def _on_metric_toggle(self, state: bool): + ui_state.params.put_bool("IsMetric", state, block=True) + self._update_toggles() + def _update_toggles(self): ui_state.update_params() self._initial_cruise_speed.refresh() diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index c093253771..bc64de5e29 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -86,6 +86,7 @@ class UIState: # Core state variables self.is_metric: bool = self.params.get_bool("IsMetric") + self.initial_cruise_speed: int = self.params.get("InitialCruiseSpeedKph", return_default=True) self.is_release = self.params.get_bool("IsReleaseBranch") self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM") self.navigation_enabled: bool = self.params.get_bool("NavigationEnabled") @@ -249,6 +250,7 @@ class UIState: self.recording_audio = self.params.get_bool("RecordAudio") and self.started self.is_metric = self.params.get_bool("IsMetric") + self.initial_cruise_speed = self.params.get("InitialCruiseSpeedKph", return_default=True) self.always_on_dm = self.params.get_bool("AlwaysOnDM") self.navigation_enabled = self.params.get_bool("NavigationEnabled") self.nav_destination_set = self.params.get("NavDestination") is not None diff --git a/openpilot/system/ui/lib/scroll_panel2.py b/openpilot/system/ui/lib/scroll_panel2.py index 19faaa542d..b53e663794 100644 --- a/openpilot/system/ui/lib/scroll_panel2.py +++ b/openpilot/system/ui/lib/scroll_panel2.py @@ -263,6 +263,10 @@ class GuiScrollPanel2: def state(self) -> ScrollState: return self._state + @property + def velocity(self) -> float: + return self._velocity + def is_touch_valid(self) -> bool: # MIN_VELOCITY_FOR_CLICKING is checked in auto-scroll state return bool(self._state != ScrollState.MANUAL_SCROLL) From 90932d9b5c4c6f8c6804fb1f8c220706bc62b107 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Thu, 15 Oct 2026 21:21:28 -0700 Subject: [PATCH 251/270] releases tweaks --- RELEASES.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index 4b1a78e8a9..ae3d2cb7e5 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,12 +1,12 @@ Version 0.11.3 (2026-XX-XX) ======================== * Autospeed - * On supported cars openpilot will decide what speed to drive intelligently based on the scene, including: + * On supported cars, openpilot will intelligently decide what speed to drive based on the scene, including: * stopping at red lights, and going at green lights * slowing down for curves * adjusting speed based on surrounding traffic - * This behavior was previously available in openpilot's beta mode called "experimental mode", but now is now out of beta and always enabled - * With openpilot deciding which speed to drive the car's cruise speed is treated as a maximum speed openpilot will not exceed + * This behavior was previously available with openpilot's "Experimental mode", but is now out of beta and always enabled + * With openpilot deciding which speed to drive, the car's cruise speed is treated as a maximum speed openpilot will not exceed Version 0.11.2 (2026-10-14) ======================= From 5ec0c02195089db6c9ff56d6315b2a230567012f Mon Sep 17 00:00:00 2001 From: Kumar <36933347+rav4kumar@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:03:15 -0700 Subject: [PATCH 252/270] Sunset Dynamic Experimental Mode (DEC) (#2056) --- openpilot/cereal/custom.capnp | 7 +- openpilot/common/params_keys.h | 1 - openpilot/selfdrive/car/card.py | 6 +- openpilot/selfdrive/car/cruise.py | 5 +- .../selfdrive/car/tests/test_cruise_speed.py | 25 +- .../controls/lib/longitudinal_planner.py | 6 +- .../ui/sunnypilot/layouts/settings/cruise.py | 9 - openpilot/selfdrive/ui/sunnypilot/ui_state.py | 3 +- .../sunnypilot/selfdrive/car/interfaces.py | 1 - .../selfdrive/car/tests/test_custom_cruise.py | 14 +- .../selfdrive/controls/lib/dec/__init__.py | 0 .../selfdrive/controls/lib/dec/constants.py | 17 - .../selfdrive/controls/lib/dec/dec.py | 388 ------------------ .../controls/lib/dec/tests/__init__.py | 0 .../lib/dec/tests/test_dec_planner_gate.py | 112 ----- .../lib/dec/tests/test_dynamic_controller.py | 91 ---- .../controls/lib/longitudinal_planner.py | 17 - openpilot/sunnypilot/sunnylink/docs/README.md | 4 +- .../sunnypilot/sunnylink/settings_ui.json | 20 - .../settings_ui_src/pages/cruise.yaml | 8 - .../sunnylink/tests/test_settings_changes.py | 5 - 21 files changed, 33 insertions(+), 706 deletions(-) delete mode 100644 openpilot/sunnypilot/selfdrive/controls/lib/dec/__init__.py delete mode 100644 openpilot/sunnypilot/selfdrive/controls/lib/dec/constants.py delete mode 100644 openpilot/sunnypilot/selfdrive/controls/lib/dec/dec.py delete mode 100644 openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/__init__.py delete mode 100644 openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dec_planner_gate.py delete mode 100644 openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dynamic_controller.py diff --git a/openpilot/cereal/custom.capnp b/openpilot/cereal/custom.capnp index cfbbbe472a..3da8999199 100644 --- a/openpilot/cereal/custom.capnp +++ b/openpilot/cereal/custom.capnp @@ -196,7 +196,6 @@ struct ModelManagerSP @0xaedffd8f31e7b55d { } struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 { - dec @0 :DynamicExperimentalControl; longitudinalPlanSource @1 :LongitudinalPlanSource; smartCruiseControl @2 :SmartCruiseControl; speedLimit @3 :SpeedLimit; @@ -205,7 +204,11 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 { events @6 :List(OnroadEventSP.Event); e2eAlerts @7 :E2eAlerts; - struct DynamicExperimentalControl { + deprecated :group { + dec @0 :DynamicExperimentalControlDEPRECATED; + } + + struct DynamicExperimentalControlDEPRECATED { state @0 :DynamicExperimentalControlState; enabled @1 :Bool; active @2 :Bool; diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 19c063fc26..82725e807e 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -240,7 +240,6 @@ inline static std::unordered_map keys = { {"ToyotaEnforceStockLongitudinal", {PERSISTENT | BACKUP, BOOL, "0"}}, {"ToyotaStopAndGoHack", {PERSISTENT | BACKUP, BOOL, "0"}}, - {"DynamicExperimentalControl", {PERSISTENT | BACKUP, BOOL, "0"}}, {"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}}, // sunnypilot model params diff --git a/openpilot/selfdrive/car/card.py b/openpilot/selfdrive/car/card.py index ddfe383d6e..2e45b18214 100755 --- a/openpilot/selfdrive/car/card.py +++ b/openpilot/selfdrive/car/card.py @@ -127,9 +127,6 @@ class Car: set_alternative_experience(self.CP, self.CP_SP, self.params) set_car_specific_params(self.CP, self.CP_SP, self.params) - # Dynamic Experimental Control - self.dynamic_experimental_control = self.params.get_bool("DynamicExperimentalControl") - openpilot_enabled_toggle = self.params.get_bool("OpenpilotEnabledToggle") controller_available = self.CI.CC is not None and openpilot_enabled_toggle and not self.CP.dashcamOnly self.CP.passive = not controller_available or self.CP.dashcamOnly @@ -214,7 +211,7 @@ class Car: self.v_cruise_helper.update_v_cruise(CS, self.sm['carControl'].enabled, self.is_metric) if self.sm['carControl'].enabled and not self.CC_prev.enabled: # Use CarState w/ buttons from the step selfdrived enables on - self.v_cruise_helper.initialize_v_cruise(self.CS_prev, self.experimental_mode, self.dynamic_experimental_control) + self.v_cruise_helper.initialize_v_cruise(self.CS_prev, self.experimental_mode) # TODO: mirror the carState.cruiseState struct? CS.vCruise = float(self.v_cruise_helper.v_cruise_kph) @@ -302,7 +299,6 @@ class Car: self.experimental_mode = self.params.get_bool("ExperimentalMode") and self.CP.openpilotLongitudinalControl # sunnypilot - self.dynamic_experimental_control = self.params.get_bool("DynamicExperimentalControl") self.v_cruise_helper.read_custom_set_speed_params() time.sleep(0.1) diff --git a/openpilot/selfdrive/car/cruise.py b/openpilot/selfdrive/car/cruise.py index b554c1a978..9aed674e34 100644 --- a/openpilot/selfdrive/car/cruise.py +++ b/openpilot/selfdrive/car/cruise.py @@ -138,13 +138,12 @@ class VCruiseHelper(VCruiseHelperSP): self.button_timers[b.type.raw] = 1 if b.pressed else 0 self.button_change_states[b.type.raw] = {"standstill": CS.cruiseState.standstill, "enabled": enabled} - def initialize_v_cruise(self, CS, experimental_mode: bool, dynamic_experimental_control: bool) -> None: + def initialize_v_cruise(self, CS, experimental_mode: bool) -> None: # initializing is handled by the PCM if self.CP.pcmCruise: return - initial_experimental_mode = experimental_mode and not dynamic_experimental_control - initial = V_CRUISE_INITIAL_EXPERIMENTAL_MODE if initial_experimental_mode else V_CRUISE_INITIAL + initial = V_CRUISE_INITIAL_EXPERIMENTAL_MODE if experimental_mode else V_CRUISE_INITIAL if any(b.type in (ButtonType.accelCruise, ButtonType.resumeCruise) for b in CS.buttonEvents) and self.v_cruise_initialized: self.v_cruise_kph = self.v_cruise_kph_last diff --git a/openpilot/selfdrive/car/tests/test_cruise_speed.py b/openpilot/selfdrive/car/tests/test_cruise_speed.py index 60af346838..c38c7c7170 100644 --- a/openpilot/selfdrive/car/tests/test_cruise_speed.py +++ b/openpilot/selfdrive/car/tests/test_cruise_speed.py @@ -59,16 +59,16 @@ class TestVCruiseHelper(OpenpilotTestCase): for _ in range(2): self.v_cruise_helper.update_v_cruise(car.CarState(cruiseState={"available": False}), enabled=False, is_metric=False) - def enable(self, v_ego, experimental_mode, dynamic_experimental_control): + def enable(self, v_ego, experimental_mode): # Simulates user pressing set with a current speed - self.v_cruise_helper.initialize_v_cruise(car.CarState(vEgo=v_ego), experimental_mode, dynamic_experimental_control) + self.v_cruise_helper.initialize_v_cruise(car.CarState(vEgo=v_ego), experimental_mode) def test_adjust_speed(self): """ Asserts speed changes on falling edges of buttons. """ - self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False, False) + self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False) for btn in (ButtonType.accelCruise, ButtonType.decelCruise): for pressed in (True, False): @@ -92,7 +92,7 @@ class TestVCruiseHelper(OpenpilotTestCase): CS.buttonEvents = [ButtonEvent(type=ButtonType.decelCruise, pressed=pressed)] self.v_cruise_helper.update_v_cruise(CS, enabled=enabled, is_metric=False) if pressed: - self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False, False) + self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False) # Expected diff on enabling. Speed should not change on falling edge of pressed assert (not pressed) == (self.v_cruise_helper.v_cruise_kph == self.v_cruise_helper.v_cruise_kph_last) @@ -102,7 +102,7 @@ class TestVCruiseHelper(OpenpilotTestCase): Asserts we don't increment set speed if user presses resume/accel to exit cruise standstill. """ - self.enable(0, False, False) + self.enable(0, False) for standstill in (True, False): for pressed in (True, False): @@ -122,7 +122,7 @@ class TestVCruiseHelper(OpenpilotTestCase): for v_ego in np.linspace(0, 100, 101): self.reset_cruise_speed_state() - self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False, False) + self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False) # first decrement speed, then perform gas pressed logic expected_v_cruise_kph = self.v_cruise_helper.v_cruise_kph - IMPERIAL_INCREMENT @@ -144,11 +144,10 @@ class TestVCruiseHelper(OpenpilotTestCase): """ for experimental_mode in (True, False): - for dynamic_experimental_control in (True, False): - for v_ego in np.linspace(0, 100, 101): - self.reset_cruise_speed_state() - assert not self.v_cruise_helper.v_cruise_initialized + for v_ego in np.linspace(0, 100, 101): + self.reset_cruise_speed_state() + assert not self.v_cruise_helper.v_cruise_initialized - self.enable(float(v_ego), experimental_mode, dynamic_experimental_control) - assert V_CRUISE_INITIAL <= self.v_cruise_helper.v_cruise_kph <= V_CRUISE_MAX - assert self.v_cruise_helper.v_cruise_initialized + self.enable(float(v_ego), experimental_mode) + assert V_CRUISE_INITIAL <= self.v_cruise_helper.v_cruise_kph <= V_CRUISE_MAX + assert self.v_cruise_helper.v_cruise_initialized diff --git a/openpilot/selfdrive/controls/lib/longitudinal_planner.py b/openpilot/selfdrive/controls/lib/longitudinal_planner.py index bf91c5e1f9..74dbfc0dd9 100755 --- a/openpilot/selfdrive/controls/lib/longitudinal_planner.py +++ b/openpilot/selfdrive/controls/lib/longitudinal_planner.py @@ -138,16 +138,16 @@ class LongitudinalPlanner(LongitudinalPlannerSP): output_a_target_e2e = sm['modelV2'].action.desiredAcceleration output_should_stop_e2e = sm['modelV2'].action.shouldStop - is_e2e = self.is_e2e(sm) + experimental_mode = sm['selfdriveState'].experimentalMode - self.a_cruise = get_cruise_accel(is_e2e, v_cruise, v_ego, + self.a_cruise = get_cruise_accel(experimental_mode, v_cruise, v_ego, self.a_cruise, steer_angle_without_offset, self.CP, self.dt, accel_coast, self.allow_throttle) cruise_should_stop = should_stop(v_ego, self.a_cruise) candidates = [(output_a_target_mpc, self.mpc.source, output_should_stop_mpc), (self.a_cruise, LongitudinalPlanSource.cruise, cruise_should_stop)] - if is_e2e: + if experimental_mode: candidates.append((output_a_target_e2e, LongitudinalPlanSource.e2e, output_should_stop_e2e)) output_a_target, self.mpc.source, _ = min(candidates, key=lambda c: c[0]) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py index 671174ac7a..61254ce624 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py @@ -82,14 +82,8 @@ class CruiseLayout(Widget): callback=lambda: self._set_current_panel(PanelType.SLA) ) - self.dec_toggle = toggle_item_sp( - title=tr("Enable Dynamic Experimental Control"), - description=tr("Enable toggle to allow the model to determine when to use sunnypilot ACC or sunnypilot End to End Longitudinal."), - param="DynamicExperimentalControl") - items = [ self.icbm_toggle, - self.dec_toggle, self.scc_v_toggle, self.scc_m_toggle, self.custom_acc_toggle, @@ -144,16 +138,13 @@ class CruiseLayout(Widget): if has_long or has_icbm: self.custom_acc_toggle.action_item.set_enabled(((has_long and not ui_state.CP.pcmCruise) or has_icbm) and ui_state.is_offroad()) - self.dec_toggle.action_item.set_enabled(has_long) self.scc_v_toggle.action_item.set_enabled(True) self.scc_m_toggle.action_item.set_enabled(True) else: ui_state.params.remove("CustomAccIncrementsEnabled") - ui_state.params.remove("DynamicExperimentalControl") ui_state.params.remove("SmartCruiseControlVision") ui_state.params.remove("SmartCruiseControlMap") self.custom_acc_toggle.action_item.set_enabled(False) - self.dec_toggle.action_item.set_enabled(False) self.scc_v_toggle.action_item.set_enabled(False) self.scc_m_toggle.action_item.set_enabled(False) diff --git a/openpilot/selfdrive/ui/sunnypilot/ui_state.py b/openpilot/selfdrive/ui/sunnypilot/ui_state.py index d130c3862a..f70da2b484 100644 --- a/openpilot/selfdrive/ui/sunnypilot/ui_state.py +++ b/openpilot/selfdrive/ui/sunnypilot/ui_state.py @@ -221,10 +221,9 @@ class UIStateSP: self.params.remove("LateralJerkTorqueController") self.params.remove("AlphaLongitudinalEnabled") - # No longitudinal control: no experimental mode or DEC + # No longitudinal control: no experimental mode if not has_long: self.params.remove("ExperimentalMode") - self.params.remove("DynamicExperimentalControl") # ICBM: clear if not available or if full longitudinal control is active if self.CP_SP is not None: diff --git a/openpilot/sunnypilot/selfdrive/car/interfaces.py b/openpilot/sunnypilot/selfdrive/car/interfaces.py index 13f67357ed..264f5168d6 100644 --- a/openpilot/sunnypilot/selfdrive/car/interfaces.py +++ b/openpilot/sunnypilot/selfdrive/car/interfaces.py @@ -81,7 +81,6 @@ def _cleanup_unsupported_params(CP: structs.CarParams, CP_SP: structs.CarParamsS if not CP.openpilotLongitudinalControl and CP_SP.pcmCruiseSpeed: cloudlog.warning("openpilot Longitudinal Control and ICBM not available, cleaning up params") - params.remove("DynamicExperimentalControl") params.remove("CustomAccIncrementsEnabled") params.remove("SmartCruiseControlVision") params.remove("SmartCruiseControlMap") diff --git a/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py b/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py index 56491e3518..257f121513 100644 --- a/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py +++ b/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py @@ -57,7 +57,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): def test_default_behavior_when_disabled(self): """Test that default increments are used when custom ACC is disabled""" self.set_custom_increments(enabled=False, short_inc=5, long_inc=10) - self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False, False) + self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False) initial_speed = self.v_cruise_helper.v_cruise_kph @@ -69,7 +69,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): def test_custom_short_press_increments(self, increment): """Test custom short press increments (1-10)""" self.set_custom_increments(enabled=True, short_inc=increment, long_inc=5) - self.enable(50 * CV.KPH_TO_MS, False, False) + self.enable(50 * CV.KPH_TO_MS, False) initial_speed = self.v_cruise_helper.v_cruise_kph self.press_button_short(ButtonType.accelCruise) @@ -86,7 +86,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): def test_custom_long_press_increments(self, increment): """Test custom long press increments (1, 5, 10)""" self.set_custom_increments(enabled=True, short_inc=1, long_inc=increment) - self.enable(50 * CV.KPH_TO_MS, False, False) + self.enable(50 * CV.KPH_TO_MS, False) initial_speed = self.v_cruise_helper.v_cruise_kph self.press_button_long(ButtonType.accelCruise) @@ -103,7 +103,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): def test_accel_decel_symmetry(self, button_type): """Test that acceleration and deceleration work symmetrically""" self.set_custom_increments(enabled=True, short_inc=3, long_inc=5) - self.enable(50 * CV.KPH_TO_MS, False, False) + self.enable(50 * CV.KPH_TO_MS, False) initial_speed = self.v_cruise_helper.v_cruise_kph self.press_button_short(button_type) @@ -123,7 +123,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): for initial, increment, expected in test_cases: self.set_custom_increments(enabled=True, short_inc=increment, long_inc=increment) self.reset_cruise_speed_state() - self.enable(initial * CV.KPH_TO_MS, False, False) + self.enable(initial * CV.KPH_TO_MS, False) self.press_button_short(ButtonType.accelCruise) assert self.v_cruise_helper.v_cruise_kph == expected @@ -132,7 +132,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): """Test that invalid values fallback to safe defaults""" # Test invalid short increment self.set_custom_increments(enabled=True, short_inc=-1, long_inc=5) - self.enable(50 * CV.KPH_TO_MS, False, False) + self.enable(50 * CV.KPH_TO_MS, False) initial_speed = self.v_cruise_helper.v_cruise_kph self.press_button_short(ButtonType.accelCruise) @@ -141,7 +141,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): # Test invalid long increment self.reset_cruise_speed_state() self.set_custom_increments(enabled=True, short_inc=1, long_inc=99) - self.enable(50 * CV.KPH_TO_MS, False, False) + self.enable(50 * CV.KPH_TO_MS, False) initial_speed = self.v_cruise_helper.v_cruise_kph self.press_button_long(ButtonType.accelCruise) diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/__init__.py b/openpilot/sunnypilot/selfdrive/controls/lib/dec/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/constants.py b/openpilot/sunnypilot/selfdrive/controls/lib/dec/constants.py deleted file mode 100644 index 4586afbc9f..0000000000 --- a/openpilot/sunnypilot/selfdrive/controls/lib/dec/constants.py +++ /dev/null @@ -1,17 +0,0 @@ -class WMACConstants: - # Lead detection parameters - LEAD_WINDOW_SIZE = 6 # Stable detection window - LEAD_PROB = 0.45 # Balanced threshold for lead detection - - # Slow down detection parameters - SLOW_DOWN_WINDOW_SIZE = 5 # Responsive but stable - SLOW_DOWN_PROB = 0.3 # Balanced threshold for slow down scenarios - - # Optimized slow down distance curve - smooth and progressive - SLOW_DOWN_BP = [0., 10., 20., 30., 40., 50., 55., 60.] - SLOW_DOWN_DIST = [32., 46., 64., 86., 108., 130., 145., 165.] - - # Slowness detection parameters - SLOWNESS_WINDOW_SIZE = 10 # Stable slowness detection - SLOWNESS_PROB = 0.55 # Clear threshold for slowness - SLOWNESS_CRUISE_OFFSET = 1.025 # Conservative cruise speed offset diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/dec.py b/openpilot/sunnypilot/selfdrive/controls/lib/dec/dec.py deleted file mode 100644 index fb854edae8..0000000000 --- a/openpilot/sunnypilot/selfdrive/controls/lib/dec/dec.py +++ /dev/null @@ -1,388 +0,0 @@ -""" -Copyright (c) 2021-, rav4kumar, sunnypilot, and a number of other contributors. - -This file is part of sunnypilot and is licensed under the MIT License. -See the LICENSE.md file in the root directory for more details. -""" -# Version = 2025-6-30 - -from openpilot.cereal import messaging -from opendbc.car import structs -from numpy import interp -from openpilot.common.params import Params -from openpilot.common.realtime import DT_MDL -from openpilot.sunnypilot.selfdrive.controls.lib.dec.constants import WMACConstants -from typing import Literal - -# d-e2e, from modeldata.h -TRAJECTORY_SIZE = 33 -SET_MODE_TIMEOUT = 15 - -# Define the valid mode types -ModeType = Literal['acc', 'blended'] - - -class SmoothKalmanFilter: - """Enhanced Kalman filter with smoothing for stable decision making.""" - - def __init__(self, initial_value=0, measurement_noise=0.1, process_noise=0.01, - alpha=1.0, smoothing_factor=0.85): - self.x = initial_value - self.P = 1.0 - self.R = measurement_noise - self.Q = process_noise - self.alpha = alpha - self.smoothing_factor = smoothing_factor - self.initialized = False - self.history = [] - self.max_history = 10 - self.confidence = 0.0 - - def add_data(self, measurement): - if len(self.history) >= self.max_history: - self.history.pop(0) - self.history.append(measurement) - - if not self.initialized: - self.x = measurement - self.initialized = True - self.confidence = 0.1 - return - - self.P = self.alpha * self.P + self.Q - - K = self.P / (self.P + self.R) - effective_K = K * (1.0 - self.smoothing_factor) + self.smoothing_factor * 0.1 - - innovation = measurement - self.x - self.x = self.x + effective_K * innovation - self.P = (1 - effective_K) * self.P - - if abs(innovation) < 0.1: - self.confidence = min(1.0, self.confidence + 0.05) - else: - self.confidence = max(0.1, self.confidence - 0.02) - - def get_value(self): - return self.x if self.initialized else None - - def get_confidence(self): - return self.confidence - - def reset_data(self): - self.initialized = False - self.history = [] - self.confidence = 0.0 - - -class ModeTransitionManager: - """Manages smooth transitions between driving modes with hysteresis.""" - - def __init__(self): - self.current_mode: ModeType = 'acc' - self.mode_confidence = {'acc': 1.0, 'blended': 0.0} - self.transition_timeout = 0 - self.min_mode_duration = 10 - self.mode_duration = 0 - self.emergency_override = False - - def request_mode(self, mode: ModeType, confidence: float = 1.0, emergency: bool = False): - # Emergency override for critical situations (stops, collisions) - if emergency: - self.emergency_override = True - self.current_mode = mode - self.transition_timeout = SET_MODE_TIMEOUT - self.mode_duration = 0 - return - - self.mode_confidence[mode] = min(1.0, self.mode_confidence[mode] + 0.1 * confidence) - for m in self.mode_confidence: - if m != mode: - self.mode_confidence[m] = max(0.0, self.mode_confidence[m] - 0.05) - - # Require minimum duration in current mode (unless emergency) - if self.mode_duration < self.min_mode_duration and not self.emergency_override: - return - - # Hysteresis: higher threshold for mode changes - confidence_threshold = 0.6 if mode != self.current_mode else 0.3 # Lower threshold for faster response - - if self.mode_confidence[mode] > confidence_threshold: - if mode != self.current_mode and self.transition_timeout == 0: - self.transition_timeout = SET_MODE_TIMEOUT - self.current_mode = mode - self.mode_duration = 0 - - def update(self): - if self.transition_timeout > 0: - self.transition_timeout -= 1 - self.mode_duration += 1 - - # Reset emergency override after some time - if self.emergency_override and self.mode_duration > 20: - self.emergency_override = False - - # Gradual confidence decay - for mode in self.mode_confidence: - self.mode_confidence[mode] *= 0.98 - - def get_mode(self) -> ModeType: - return self.current_mode - - -class DynamicExperimentalController: - def __init__(self, CP: structs.CarParams, mpc, params=None): - self._CP = CP - self._mpc = mpc - self._params = params or Params() - self._enabled: bool = self._params.get_bool("DynamicExperimentalControl") - self._active: bool = False - self._frame: int = 0 - self._urgency = 0.0 - - self._mode_manager = ModeTransitionManager() - - # Smooth filters for stable decision making with faster response for critical scenarios - self._lead_filter = SmoothKalmanFilter( - measurement_noise=0.15, - process_noise=0.05, - alpha=1.02, - smoothing_factor=0.8 - ) - - self._slow_down_filter = SmoothKalmanFilter( - measurement_noise=0.1, - process_noise=0.1, - alpha=1.05, - smoothing_factor=0.7 - ) - - self._slowness_filter = SmoothKalmanFilter( - measurement_noise=0.1, - process_noise=0.06, - alpha=1.015, - smoothing_factor=0.92 - ) - - self._mpc_fcw_filter = SmoothKalmanFilter( - measurement_noise=0.2, - process_noise=0.1, - alpha=1.1, - smoothing_factor=0.5 - ) - self._has_lead_filtered = False - self._has_slow_down = False - self._has_slowness = False - self._has_mpc_fcw = False - self._v_ego_kph = 0.0 - self._v_cruise_kph = 0.0 - self._has_standstill = False - self._mpc_fcw_crash_cnt = 0 - self._standstill_count = 0 - # debug - self._endpoint_x = float('inf') - self._expected_distance = 0.0 - self._trajectory_valid = False - - def _read_params(self) -> None: - if self._frame % int(1. / DT_MDL) == 0: - self._enabled = self._params.get_bool("DynamicExperimentalControl") - - def mode(self) -> str: - return self._mode_manager.get_mode() - - def enabled(self) -> bool: - return self._enabled - - def active(self) -> bool: - return self._active - - def set_mpc_fcw_crash_cnt(self) -> None: - """Set MPC FCW crash count""" - self._mpc_fcw_crash_cnt = self._mpc.crash_cnt - - def _update_calculations(self, sm: messaging.SubMaster) -> None: - car_state = sm['carState'] - lead_one = sm['radarState'].leadOne - md = sm['modelV2'] - - self._v_ego_kph = car_state.vEgo * 3.6 - self._v_cruise_kph = car_state.vCruise - self._has_standstill = car_state.standstill - - # standstill detection - if self._has_standstill: - self._standstill_count = min(20, self._standstill_count + 1) - else: - self._standstill_count = max(0, self._standstill_count - 1) - - # Lead detection - self._lead_filter.add_data(float(lead_one.present)) - lead_value = self._lead_filter.get_value() or 0.0 - self._has_lead_filtered = lead_value > WMACConstants.LEAD_PROB - - # MPC FCW detection - fcw_filtered_value = self._mpc_fcw_filter.get_value() or 0.0 - self._mpc_fcw_filter.add_data(float(self._mpc_fcw_crash_cnt > 0)) - self._has_mpc_fcw = fcw_filtered_value > 0.5 - - # Slow down detection - self._calculate_slow_down(md) - - # Slowness detection - if not (self._standstill_count > 5) and not self._has_slow_down: - current_slowness = float(self._v_ego_kph <= (self._v_cruise_kph * WMACConstants.SLOWNESS_CRUISE_OFFSET)) - self._slowness_filter.add_data(current_slowness) - slowness_value = self._slowness_filter.get_value() or 0.0 - - # Hysteresis for slowness - threshold = WMACConstants.SLOWNESS_PROB * (0.8 if self._has_slowness else 1.1) - self._has_slowness = slowness_value > threshold - - def _calculate_slow_down(self, md): - """Calculate urgency based on trajectory endpoint vs expected distance.""" - - # Reset to safe defaults - urgency = 0.0 - self._endpoint_x = float('inf') - self._trajectory_valid = False - - #Require exact trajectory size - position_valid = len(md.position.x) == TRAJECTORY_SIZE - orientation_valid = len(md.orientation.x) == TRAJECTORY_SIZE - - if not (position_valid and orientation_valid): - # Invalid trajectory - this itself might indicate a stop scenario - # Apply moderate urgency for incomplete trajectories at speed - if self._v_ego_kph > 20.0: - urgency = 0.3 - - self._slow_down_filter.add_data(urgency) - urgency_filtered = self._slow_down_filter.get_value() or 0.0 - self._has_slow_down = urgency_filtered > WMACConstants.SLOW_DOWN_PROB - self._urgency = urgency_filtered - return - - # We have a valid full trajectory - self._trajectory_valid = True - - # Use the exact endpoint (33rd point, index 32) - endpoint_x = md.position.x[TRAJECTORY_SIZE - 1] - self._endpoint_x = endpoint_x - - # Get expected distance based on current speed using tuned constants - expected_distance = interp(self._v_ego_kph, - WMACConstants.SLOW_DOWN_BP, - WMACConstants.SLOW_DOWN_DIST) - self._expected_distance = expected_distance - - # Calculate urgency based on trajectory shortage - if endpoint_x < expected_distance: - shortage = expected_distance - endpoint_x - shortage_ratio = shortage / expected_distance - - # Base urgency on shortage ratio - urgency = min(1.0, shortage_ratio * 2.0) - - # Increase urgency for very short trajectories (imminent stops) - critical_distance = expected_distance * 0.3 - if endpoint_x < critical_distance: - urgency = min(1.0, urgency * 2.0) - - # Speed-based urgency adjustment - if self._v_ego_kph > 25.0: - speed_factor = 1.0 + (self._v_ego_kph - 25.0) / 80.0 - urgency = min(1.0, urgency * speed_factor) - - # Apply filtering but with less smoothing for stops - self._slow_down_filter.add_data(urgency) - urgency_filtered = self._slow_down_filter.get_value() or 0.0 - - # Update state with lower threshold for better stop detection - self._has_slow_down = urgency_filtered > (WMACConstants.SLOW_DOWN_PROB * 0.8) - self._urgency = urgency_filtered - - def _radarless_mode(self) -> None: - """Radarless mode decision logic with emergency handling.""" - - # EMERGENCY: MPC FCW - immediate blended mode - if self._has_mpc_fcw: - self._mode_manager.request_mode('blended', confidence=1.0, emergency=True) - return - - # Standstill: use blended - if self._standstill_count > 3: - self._mode_manager.request_mode('blended', confidence=0.9) - return - - # Slow down scenarios: emergency for high urgency, normal for lower urgency - if self._has_slow_down: - if self._urgency > 0.7: - # Emergency: immediate blended mode for high urgency stops - self._mode_manager.request_mode('blended', confidence=1.0, emergency=True) - else: - # Normal: blended with urgency-based confidence - confidence = min(1.0, self._urgency * 1.5) - self._mode_manager.request_mode('blended', confidence=confidence) - return - - # Driving slow: use ACC (but not if actively slowing down) - if self._has_slowness and not self._has_slow_down: - self._mode_manager.request_mode('acc', confidence=0.8) - return - - # Default: ACC - self._mode_manager.request_mode('acc', confidence=0.7) - - def _radar_mode(self) -> None: - """Radar mode with emergency handling.""" - - # EMERGENCY: MPC FCW - immediate blended mode - if self._has_mpc_fcw: - self._mode_manager.request_mode('blended', confidence=1.0, emergency=True) - return - - # If lead detected and not in standstill: always use ACC - if self._has_lead_filtered and not (self._standstill_count > 3): - self._mode_manager.request_mode('acc', confidence=1.0) - return - - # Slow down scenarios: emergency for high urgency, normal for lower urgency - if self._has_slow_down: - if self._urgency > 0.7: - # Emergency: immediate blended mode for high urgency stops - self._mode_manager.request_mode('blended', confidence=1.0, emergency=True) - else: - # Normal: blended with urgency-based confidence - confidence = min(1.0, self._urgency * 1.3) - self._mode_manager.request_mode('blended', confidence=confidence) - return - - # Standstill: use blended - if self._standstill_count > 3: - self._mode_manager.request_mode('blended', confidence=0.9) - return - - # Driving slow: use ACC (but not if actively slowing down) - if self._has_slowness and not self._has_slow_down: - self._mode_manager.request_mode('acc', confidence=0.8) - return - - # Default: ACC - self._mode_manager.request_mode('acc', confidence=0.7) - - def update(self, sm: messaging.SubMaster) -> None: - self._read_params() - - self.set_mpc_fcw_crash_cnt() - - self._update_calculations(sm) - - if self._CP.radarUnavailable: - self._radarless_mode() - else: - self._radar_mode() - - self._mode_manager.update() - self._active = sm['selfdriveState'].experimentalMode and self._enabled - self._frame += 1 diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/__init__.py b/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dec_planner_gate.py b/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dec_planner_gate.py deleted file mode 100644 index 1f5c577028..0000000000 --- a/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dec_planner_gate.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. - -This file is part of sunnypilot and is licensed under the MIT License. -See the LICENSE.md file in the root directory for more details. -""" -from typing import cast - -from openpilot.cereal import custom, messaging -from opendbc.car import structs -from openpilot.common.test import OpenpilotTestCase -from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner, LongitudinalPlanSource -from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController - -V_EGO = 20.0 -E2E_ACCEL = -3.0 # low enough that e2e wins the min() whenever it is a candidate - - -class MockDec: - def __init__(self, active: bool, mode: str): - self._active = active - self._mode = mode - - def update(self, sm): - pass - - def active(self) -> bool: - return self._active - - def mode(self) -> str: - return self._mode - - def enabled(self) -> bool: - return True - - -class MockSubMaster(dict): - def __init__(self, services: dict): - super().__init__(services) - self.valid = dict.fromkeys(services, True) - self.logMonoTime = dict.fromkeys(services, 0) - self.updated = dict.fromkeys(services, True) - self.recv_frame = dict.fromkeys(services, 1) - - def all_checks(self, service_list=None) -> bool: - return True - - -def build_sm(experimental_mode: bool) -> MockSubMaster: - services = {} - for service in ("radarState", "controlsState", "vehicleParameters", "carStateSP", - "liveMapDataSP", "gpsLocationExternal", "gpsLocation"): - services[service] = getattr(messaging.new_message(service), service) - - car_state = messaging.new_message('carState') - car_state.carState.vEgo = V_EGO - car_state.carState.vCruise = 100.0 - car_state.carState.vCruiseCluster = 100.0 - services['carState'] = car_state.carState.as_reader() - - selfdrive_state = messaging.new_message('selfdriveState') - selfdrive_state.selfdriveState.experimentalMode = experimental_mode - selfdrive_state.selfdriveState.enabled = True - services['selfdriveState'] = selfdrive_state.selfdriveState.as_reader() - - car_control = messaging.new_message('carControl') - car_control.carControl.enabled = True - services['carControl'] = car_control.carControl.as_reader() - - model = messaging.new_message('modelV2') - model.modelV2.orientationRate.z = [0.01] * 33 # nonzero: a straight path divides by zero in SCC vision - model.modelV2.velocity.x = [V_EGO] * 33 - model.modelV2.position.x = [float(i) for i in range(33)] - model.modelV2.action.desiredAcceleration = E2E_ACCEL - services['modelV2'] = model.modelV2.as_reader() - - return MockSubMaster(services) - - -def build_planner(dec_active: bool, dec_mode: str) -> LongitudinalPlanner: - CP = structs.CarParams() - CP.steerRatio = 15.0 - CP.wheelbase = 2.7 - CP.longitudinalActuatorDelay = 0.2 - CP_SP = custom.CarParamsSP.new_message().as_reader() - - planner = LongitudinalPlanner(CP, CP_SP, init_v=V_EGO) - planner.dec = cast(DynamicExperimentalController, MockDec(dec_active, dec_mode)) - return planner - - -class TestDecPlannerGate(OpenpilotTestCase): - """The e2e candidate must be gated on is_e2e(), not raw experimentalMode.""" - - def _source(self, experimental_mode: bool, dec_active: bool, dec_mode: str) -> LongitudinalPlanSource: - planner = build_planner(dec_active, dec_mode) - planner.update(build_sm(experimental_mode)) - return planner.mpc.source - - def test_no_e2e_when_experimental_mode_off(self): - assert self._source(False, False, 'acc') != LongitudinalPlanSource.e2e - - def test_e2e_when_dec_inactive(self): - # DEC off: behavior must match upstream - assert self._source(True, False, 'acc') == LongitudinalPlanSource.e2e - - def test_e2e_when_dec_blended(self): - assert self._source(True, True, 'blended') == LongitudinalPlanSource.e2e - - def test_no_e2e_when_dec_holds_acc(self): - # the regression - assert self._source(True, True, 'acc') != LongitudinalPlanSource.e2e diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dynamic_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dynamic_controller.py deleted file mode 100644 index 4fec6eaa52..0000000000 --- a/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dynamic_controller.py +++ /dev/null @@ -1,91 +0,0 @@ -from openpilot.common.test import OpenpilotTestCase -from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController - -class MockLeadOne: - def __init__(self, present=0.0): - self.present = present - -class MockRadarState: - def __init__(self, present=0.0): - self.leadOne = MockLeadOne(present=present) - -class MockCarState: - def __init__(self, vEgo=0.0, vCruise=0.0, standstill=False): - self.vEgo = vEgo - self.vCruise = vCruise - self.standstill = standstill - -class MockModelData: - def __init__(self, valid=True): - size = 33 if valid else 10 # incomplete if invalid - self.position = type("Pos", (), {"x": [0.0] * size})() - self.orientation = type("Ori", (), {"x": [0.0] * size})() - -class MockSelfDriveState: - def __init__(self, experimentalMode=False): - self.experimentalMode = experimentalMode - -class MockParams: - def get_bool(self, name): - return True - -def default_sm(): - sm = { - 'carState': MockCarState(vEgo=10.0, vCruise=20.0), - 'radarState': MockRadarState(present=1.0), - 'modelV2': MockModelData(valid=True), - 'selfdriveState': MockSelfDriveState(experimentalMode=True), - } - return sm - -def mock_cp(): - class CP: - radarUnavailable = False - return CP() - -def mock_mpc(): - class MPC: - crash_cnt = 0 - return MPC() - -# Fake Kalman Filter that always returns a given value -class FakeKalman: - def __init__(self, value=1.0): - self.value = value - def add_data(self, v): pass - def get_value(self): return self.value - def get_confidence(self): return 1.0 - def reset_data(self): pass - -class TestDynamicExperimentalController(OpenpilotTestCase): - def test_initial_mode_is_acc(self, mock_cp, mock_mpc): - controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) - assert controller.mode() == "acc" - - def test_standstill_triggers_blended(self, mock_cp, mock_mpc, default_sm): - controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) - default_sm['carState'].standstill = True - for _ in range(10): - controller.update(default_sm) - assert controller.mode() == "blended" - - def test_emergency_blended_on_fcw(self, mock_cp, mock_mpc, default_sm): - controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) - mock_mpc.crash_cnt = 1 # simulate FCW - for _ in range(2): - controller.update(default_sm) - assert controller.mode() == "blended" - - def test_radarless_slowdown_triggers_blended(self, mock_cp, mock_mpc, default_sm): - mock_cp.radarUnavailable = True - controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) - - # Force conditions to simulate slowdown - controller._slow_down_filter = FakeKalman(value=1.0) # ty: ignore[invalid-assignment] - controller._v_ego_kph = 35.0 - default_sm['modelV2'] = MockModelData(valid=False) # Incomplete trajectory - - for _ in range(3): - controller.update(default_sm) - - assert controller.mode() == "blended" diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py b/openpilot/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py index f1e0c36416..a579e61c59 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py @@ -9,7 +9,6 @@ from openpilot.cereal import messaging, custom from opendbc.car import structs from openpilot.common.constants import CV from openpilot.selfdrive.car.cruise import V_CRUISE_MAX -from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController from openpilot.sunnypilot.selfdrive.controls.lib.e2e_alerts_helper import E2EAlertsHelper from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.smart_cruise_control import SmartCruiseControl from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_assist import SpeedLimitAssist @@ -17,7 +16,6 @@ from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_resolve from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP from openpilot.sunnypilot.models.helpers import get_active_bundle -DecState = custom.LongitudinalPlanSP.DynamicExperimentalControl.DynamicExperimentalControlState LongitudinalPlanSource = custom.LongitudinalPlanSP.LongitudinalPlanSource @@ -25,7 +23,6 @@ class LongitudinalPlannerSP: def __init__(self, CP: structs.CarParams, CP_SP: structs.CarParamsSP, mpc): self.events_sp = EventsSP() self.resolver = SpeedLimitResolver() - self.dec = DynamicExperimentalController(CP, mpc) self.scc = SmartCruiseControl() self.resolver = SpeedLimitResolver() self.sla = SpeedLimitAssist(CP, CP_SP) @@ -36,13 +33,6 @@ class LongitudinalPlannerSP: self.output_v_target = 0. self.output_a_target = 0. - def is_e2e(self, sm: messaging.SubMaster) -> bool: - experimental_mode = sm['selfdriveState'].experimentalMode - if not self.dec.active(): - return experimental_mode - - return experimental_mode and self.dec.mode() == "blended" - def update_targets(self, sm: messaging.SubMaster, v_ego: float, a_ego: float, v_cruise: float) -> tuple[float, float]: CS = sm['carState'] v_cruise_cluster_kph = min(CS.vCruiseCluster, V_CRUISE_MAX) @@ -75,7 +65,6 @@ class LongitudinalPlannerSP: def update(self, sm: messaging.SubMaster) -> None: self.events_sp.clear() - self.dec.update(sm) self.e2e_alerts_helper.update(sm, self.events_sp) def publish_longitudinal_plan_sp(self, sm: messaging.SubMaster, pm: messaging.PubMaster) -> None: @@ -89,12 +78,6 @@ class LongitudinalPlannerSP: longitudinalPlanSP.aTarget = float(self.output_a_target) longitudinalPlanSP.events = self.events_sp.to_msg() - # Dynamic Experimental Control - dec = longitudinalPlanSP.dec - dec.state = DecState.blended if self.dec.mode() == 'blended' else DecState.acc - dec.enabled = self.dec.enabled() - dec.active = self.dec.active() - # Smart Cruise Control smartCruiseControl = longitudinalPlanSP.smartCruiseControl # Vision Control diff --git a/openpilot/sunnypilot/sunnylink/docs/README.md b/openpilot/sunnypilot/sunnylink/docs/README.md index 62d46677e6..17320073f4 100644 --- a/openpilot/sunnypilot/sunnylink/docs/README.md +++ b/openpilot/sunnypilot/sunnylink/docs/README.md @@ -367,8 +367,8 @@ enablement: enablement: - type: any conditions: - - {type: param, key: ExperimentalMode, equals: true} - - {type: param, key: DynamicExperimentalControl, equals: true} + - {type: param, key: SmartCruiseControlVision, equals: true} + - {type: param, key: SmartCruiseControlMap, equals: true} ``` **Mixed: capability AND param** (only on longitudinal cars when ShowAdvancedControls is on): diff --git a/openpilot/sunnypilot/sunnylink/settings_ui.json b/openpilot/sunnypilot/sunnylink/settings_ui.json index 63bf342fcb..e038588143 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui.json +++ b/openpilot/sunnypilot/sunnylink/settings_ui.json @@ -599,26 +599,6 @@ } ] }, - { - "key": "DynamicExperimentalControl", - "widget": "toggle", - "title": "Dynamic Experimental Control", - "description": "Let the model decide when to use sunnypilot ACC or sunnypilot End to End Longitudinal.", - "visibility": [ - { - "type": "capability", - "field": "has_longitudinal_control", - "equals": true - } - ], - "enablement": [ - { - "type": "capability", - "field": "has_longitudinal_control", - "equals": true - } - ] - }, { "key": "DisengageOnAccelerator", "widget": "toggle", diff --git a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml index 3ef73e0fb0..662e0205af 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml +++ b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml @@ -16,14 +16,6 @@ sections: title: Experimental Mode enablement: - $ref: '#/macros/longitudinal' - - key: DynamicExperimentalControl - widget: toggle - title: Dynamic Experimental Control - description: Let the model decide when to use sunnypilot ACC or sunnypilot End to End Longitudinal. - visibility: - - $ref: '#/macros/longitudinal' - enablement: - - $ref: '#/macros/longitudinal' - key: DisengageOnAccelerator widget: toggle title: Disengage Cruise on Accelerator Pedal diff --git a/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py b/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py index d9b1267a10..8c0be866ef 100644 --- a/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py +++ b/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py @@ -201,11 +201,6 @@ class TestSpuriousOffroadGatesDropped(OpenpilotTestCase): assert item is not None assert "offroad_only" not in _flatten_rule_types(item.get("enablement")) - def test_dynamic_experimental_has_no_offroad_only(self, schema): - item = _find_item(schema, "DynamicExperimentalControl") - assert item is not None - assert "offroad_only" not in _flatten_rule_types(item.get("enablement")) - class TestNotEngagedReplacement(OpenpilotTestCase): @parameterized.expand([ From f20d9ea5e6406c54f774ce8531a6c969d9de01d8 Mon Sep 17 00:00:00 2001 From: royjr Date: Fri, 16 Oct 2026 12:33:43 -0400 Subject: [PATCH 253/270] chestnut: show icon with mads engagement (#1980) * ui engaged * try simple * Revert "try simple" This reverts commit b9513850801986bf0bed855898b2efd68b8f1e8a. * move to sp * Update opendbc_repo * Revert "Update opendbc_repo" This reverts commit d3d8a351eafd16b264b43996edb7c0c5c739e259. * Update opendbc_repo * Update opendbc_repo --- .../selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py b/openpilot/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py index 9d39d01727..83b619cc9a 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/onroad/hud_renderer.py @@ -8,17 +8,24 @@ import pyray as rl from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer from openpilot.selfdrive.ui.sunnypilot.onroad.blind_spot_indicators import BlindSpotIndicators +from openpilot.selfdrive.ui.ui_state import ui_state class HudRendererSP(HudRenderer): def __init__(self): super().__init__() self.blind_spot_indicators = BlindSpotIndicators() + self._sp_engaged: bool = False def _update_state(self) -> None: super()._update_state() self.blind_spot_indicators.update() + sp_engaged = ui_state.engaged + if sp_engaged != self._sp_engaged: + self._chestnut_fade_time = rl.get_time() if sp_engaged else 0 + self._sp_engaged = sp_engaged + def _render(self, rect: rl.Rectangle) -> None: super()._render(rect) self.blind_spot_indicators.render(rect) From 0d57eadd74302313b24f7f0bd9b10fd98612f062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Sch=C3=A4fer?= Date: Fri, 16 Oct 2026 09:47:48 -0700 Subject: [PATCH 254/270] Remove obsolete Chill mode code (#39097) * Remove obsolete Chill mode code * Remove obsolete Chill mode test variants * Use measured ego motion for stock ACC planning * Restore roadmap entry * Preserve stock ACC path colors and camera view * remove allowThrottle * Deprecate unused experimentalMode field --------- Co-authored-by: Shane Smiskol --- openpilot/cereal/log.capnp | 2 +- openpilot/selfdrive/assets/icons/couch.png | 3 - openpilot/selfdrive/assets/icons/couch.svg | 3 - .../selfdrive/assets/icons/experimental.png | 3 - .../selfdrive/assets/icons/experimental.svg | 3 - .../assets/icons/experimental_grey.png | 3 - .../assets/icons/experimental_grey.svg | 3 - .../assets/icons/experimental_white.png | 3 - .../assets/icons/experimental_white.svg | 3 - .../assets/icons_mici/experimental_mode.png | 3 - .../selfdrive/car/tests/test_cruise_speed.py | 8 +- .../selfdrive/controls/lib/accel_boost.py | 3 +- .../controls/lib/longitudinal_planner.py | 60 ++-------- openpilot/selfdrive/controls/plannerd.py | 2 +- .../controls/tests/test_following_distance.py | 11 +- openpilot/selfdrive/selfdrived/selfdrived.py | 1 - .../test/longitudinal_maneuvers/maneuver.py | 13 +-- .../test/longitudinal_maneuvers/plant.py | 14 +-- .../test_longitudinal.py | 49 +-------- .../test/process_replay/migration.py | 3 +- .../test/process_replay/process_replay.py | 2 +- .../selfdrive/ui/layouts/settings/firehose.py | 2 +- .../ui/mici/layouts/settings/firehose.py | 2 +- .../ui/mici/onroad/augmented_road_view.py | 2 +- .../ui/mici/onroad/model_renderer.py | 104 ++++-------------- .../ui/onroad/augmented_road_view.py | 2 +- .../selfdrive/ui/onroad/model_renderer.py | 79 +++---------- .../selfdrive/ui/tests/diff/replay_script.py | 1 - .../longitudinal_maneuvers/maneuversd.py | 1 - .../mpc_longitudinal_tuning_report.py | 16 +-- openpilot/tools/replay/logreader.cc | 1 - 31 files changed, 77 insertions(+), 328 deletions(-) delete mode 100644 openpilot/selfdrive/assets/icons/couch.png delete mode 100644 openpilot/selfdrive/assets/icons/couch.svg delete mode 100644 openpilot/selfdrive/assets/icons/experimental.png delete mode 100644 openpilot/selfdrive/assets/icons/experimental.svg delete mode 100644 openpilot/selfdrive/assets/icons/experimental_grey.png delete mode 100644 openpilot/selfdrive/assets/icons/experimental_grey.svg delete mode 100644 openpilot/selfdrive/assets/icons/experimental_white.png delete mode 100644 openpilot/selfdrive/assets/icons/experimental_white.svg delete mode 100644 openpilot/selfdrive/assets/icons_mici/experimental_mode.png diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 4980b296fe..5acae0ae17 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -822,7 +822,6 @@ struct SelfdriveState { alertHudVisual @12 :Car.CarControl.HUDControl.VisualAlert; # configurable driving settings - experimentalMode @10 :Bool; personality @11 :LongitudinalPersonality; enum AudibleAlert { @@ -866,6 +865,7 @@ struct SelfdriveState { deprecated :group { alertSound @8 :Car.CarControl.HUDControl.AudibleAlert; + experimentalMode @10 :Bool; } } diff --git a/openpilot/selfdrive/assets/icons/couch.png b/openpilot/selfdrive/assets/icons/couch.png deleted file mode 100644 index 677ad0f9ee..0000000000 --- a/openpilot/selfdrive/assets/icons/couch.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:911f59f248015600da7ecc689398103d47dfc57f6be17ac8c8e543a726a6c64b -size 3311 diff --git a/openpilot/selfdrive/assets/icons/couch.svg b/openpilot/selfdrive/assets/icons/couch.svg deleted file mode 100644 index f56970f289..0000000000 --- a/openpilot/selfdrive/assets/icons/couch.svg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:dd5d8d3fce5e30662797f7eeed224f5775b2cabcc055f01f5ebf6e7b2657b616 -size 1801 diff --git a/openpilot/selfdrive/assets/icons/experimental.png b/openpilot/selfdrive/assets/icons/experimental.png deleted file mode 100644 index 2332fe11d0..0000000000 --- a/openpilot/selfdrive/assets/icons/experimental.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1e58deb1778cf2826339f27e9f09eecc79ea137c1436210c14b71c352a649c77 -size 34953 diff --git a/openpilot/selfdrive/assets/icons/experimental.svg b/openpilot/selfdrive/assets/icons/experimental.svg deleted file mode 100644 index 8a97cdeac1..0000000000 --- a/openpilot/selfdrive/assets/icons/experimental.svg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b7ab989c9fe22e7d2119bac5284cdccfba2889477d69f5f6c6b4470c7ee0aab3 -size 1801 diff --git a/openpilot/selfdrive/assets/icons/experimental_grey.png b/openpilot/selfdrive/assets/icons/experimental_grey.png deleted file mode 100644 index 058c3e1358..0000000000 --- a/openpilot/selfdrive/assets/icons/experimental_grey.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8f37a02dd914405c6f86f415700dd5985eb976b923e7abd6580d2da76533594e -size 9466 diff --git a/openpilot/selfdrive/assets/icons/experimental_grey.svg b/openpilot/selfdrive/assets/icons/experimental_grey.svg deleted file mode 100644 index 25ab33f388..0000000000 --- a/openpilot/selfdrive/assets/icons/experimental_grey.svg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:90d441310f8e1833c661d8a4f0546aab2eb50eb21cc21beb0aff0d27b5ee6066 -size 1571 diff --git a/openpilot/selfdrive/assets/icons/experimental_white.png b/openpilot/selfdrive/assets/icons/experimental_white.png deleted file mode 100644 index 6a9cfc4407..0000000000 --- a/openpilot/selfdrive/assets/icons/experimental_white.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6b2dad33fead9f064c3a548651d6ef37daf82b6127c329683f538ec6e986ecbc -size 11204 diff --git a/openpilot/selfdrive/assets/icons/experimental_white.svg b/openpilot/selfdrive/assets/icons/experimental_white.svg deleted file mode 100644 index 51d7698947..0000000000 --- a/openpilot/selfdrive/assets/icons/experimental_white.svg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:82648254da89edb0cf65ef63fc5e6e0741414051bfea8a40449132fd61b1ed0f -size 1533 diff --git a/openpilot/selfdrive/assets/icons_mici/experimental_mode.png b/openpilot/selfdrive/assets/icons_mici/experimental_mode.png deleted file mode 100644 index 75850d08f5..0000000000 --- a/openpilot/selfdrive/assets/icons_mici/experimental_mode.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:01841b602632c66ab14a8e52b874a1623f09641dc2ef0620f4e2d00bb4a913f3 -size 16243 diff --git a/openpilot/selfdrive/car/tests/test_cruise_speed.py b/openpilot/selfdrive/car/tests/test_cruise_speed.py index cc8597e689..b25de15466 100644 --- a/openpilot/selfdrive/car/tests/test_cruise_speed.py +++ b/openpilot/selfdrive/car/tests/test_cruise_speed.py @@ -13,7 +13,7 @@ ButtonEvent = car.CarState.ButtonEvent ButtonType = car.CarState.ButtonEvent.Type -def run_cruise_simulation(cruise, e2e, personality, t_end=20.): +def run_cruise_simulation(cruise, personality, t_end=20.): man = Maneuver( '', duration=t_end, @@ -23,7 +23,6 @@ def run_cruise_simulation(cruise, e2e, personality, t_end=20.): cruise_values=[cruise], prob_lead_values=[0.0], breakpoints=[0.], - e2e=e2e, personality=personality, ) valid, output = man.evaluate() @@ -31,8 +30,7 @@ def run_cruise_simulation(cruise, e2e, personality, t_end=20.): return output[-1, 3] -@parameterized_class(("e2e", "personality", "speed"), itertools.product( - [True, False], # e2e +@parameterized_class(("personality", "speed"), itertools.product( log.LongitudinalPersonality.schema.enumerants, # personality [5,35])) # speed class TestCruiseSpeed(OpenpilotTestCase): @@ -40,7 +38,7 @@ class TestCruiseSpeed(OpenpilotTestCase): print(f'Testing {self.speed} m/s') cruise_speed = float(self.speed) - simulation_steady_state = run_cruise_simulation(cruise_speed, self.e2e, self.personality) + simulation_steady_state = run_cruise_simulation(cruise_speed, self.personality) self.assertAlmostEqual(simulation_steady_state, cruise_speed, delta=.01, msg=f'Did not reach {self.speed} m/s') diff --git a/openpilot/selfdrive/controls/lib/accel_boost.py b/openpilot/selfdrive/controls/lib/accel_boost.py index 6059f8dabc..17920c6daa 100644 --- a/openpilot/selfdrive/controls/lib/accel_boost.py +++ b/openpilot/selfdrive/controls/lib/accel_boost.py @@ -20,8 +20,7 @@ class AccelBoost: v_ego = sm['carState'].vEgo speed_scale = np.interp(v_ego, [ACCEL_BOOST_MIN_SPEED, 2 * ACCEL_BOOST_MIN_SPEED], [0.0, 1.0]) boost_scale = speed_scale * np.interp(output_a_target_e2e, [-1.0, -0.5, 5.0], [0.0, 1.0, 1.0], right=0.0) - model_limited = (sm['selfdriveState'].experimentalMode and - output_a_target_e2e + self.total_boost * boost_scale < min(output_a_target_mpc, a_cruise) - 0.1) + model_limited = output_a_target_e2e + self.total_boost * boost_scale < min(output_a_target_mpc, a_cruise) - 0.1 if not enabled or not gas_pressed: self.boost_this_override = 0.0 diff --git a/openpilot/selfdrive/controls/lib/longitudinal_planner.py b/openpilot/selfdrive/controls/lib/longitudinal_planner.py index 125b4378a3..3af007395f 100755 --- a/openpilot/selfdrive/controls/lib/longitudinal_planner.py +++ b/openpilot/selfdrive/controls/lib/longitudinal_planner.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -import math import numpy as np import openpilot.cereal.messaging as messaging @@ -16,39 +15,15 @@ from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N, get_accel_ from openpilot.selfdrive.car.cruise import V_CRUISE_MAX, V_CRUISE_UNSET from openpilot.common.swaglog import cloudlog -A_CRUISE_MAX_VALS = [1.6, 1.2, 0.8, 0.6] -A_CRUISE_MAX_BP = [0., 10.0, 25., 40.] J_CRUISE_VALS = [1.6, 1.2, 0.8, 0.6] +J_CRUISE_BP = [0., 10.0, 25., 40.] A_CRUISE_MIN = -0.5 CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N] -ALLOW_THROTTLE_THRESHOLD = 0.4 -MIN_ALLOW_THROTTLE_SPEED = 2.5 -# Lookup table for turns -_A_TOTAL_MAX_V = [1.7, 3.2] -_A_TOTAL_MAX_BP = [20., 40.] -def get_max_accel(v_ego): - return np.interp(v_ego, A_CRUISE_MAX_BP, A_CRUISE_MAX_VALS) - -def get_coast_accel(pitch): - return np.sin(pitch) * -5.65 - 0.3 # fitted from data using xx/projects/allow_throttle/compute_coast_accel.py - -def get_cruise_accel(e2e, v_cruise, v_ego, a_cruise_prev, angle_steers, CP, dt, accel_coast, allow_throttle): - max_accel = ACCEL_MAX if e2e else get_max_accel(v_ego) - - if not e2e: - a_total_max = np.interp(v_ego, _A_TOTAL_MAX_BP, _A_TOTAL_MAX_V) - a_y = v_ego ** 2 * angle_steers * CV.DEG_TO_RAD / (CP.steerRatio * CP.wheelbase) - a_x_allowed = math.sqrt(max(a_total_max ** 2 - a_y ** 2, 0.)) - max_accel = min(max_accel, a_x_allowed) - if not allow_throttle: - clipped_accel_coast = max(accel_coast, ACCEL_MIN) - coast_limit = np.interp(v_ego, [MIN_ALLOW_THROTTLE_SPEED, MIN_ALLOW_THROTTLE_SPEED*2], [max_accel, clipped_accel_coast]) - max_accel = min(max_accel, coast_limit) - - target_accel = np.clip(v_cruise - v_ego, A_CRUISE_MIN, max_accel) - j_cruise = np.interp(v_ego, A_CRUISE_MAX_BP, J_CRUISE_VALS) +def get_cruise_accel(v_cruise, v_ego, a_cruise_prev, dt): + target_accel = np.clip(v_cruise - v_ego, A_CRUISE_MIN, ACCEL_MAX) + j_cruise = np.interp(v_ego, J_CRUISE_BP, J_CRUISE_VALS) target_accel = float(np.clip(target_accel, a_cruise_prev - j_cruise * dt, a_cruise_prev + j_cruise * dt)) return target_accel @@ -60,7 +35,6 @@ class LongitudinalPlanner: self.mpc = LongitudinalMpc(dt=dt) self.fcw = False self.dt = dt - self.allow_throttle = True self.accel_boost = AccelBoost() self.v_desired_filter = FirstOrderFilter(init_v, 2.0, self.dt) @@ -73,11 +47,6 @@ class LongitudinalPlanner: self.j_desired_trajectory = np.zeros(CONTROL_N) def update(self, sm): - if len(sm['carControl'].orientationNED) == 3: - accel_coast = get_coast_accel(sm['carControl'].orientationNED[1]) - else: - accel_coast = ACCEL_MAX - v_ego = sm['carState'].vEgo v_cruise_kph = min(sm['carState'].vCruise, V_CRUISE_MAX) v_cruise = v_cruise_kph * CV.KPH_TO_MS @@ -92,12 +61,6 @@ class LongitudinalPlanner: v_cruise_initialized = sm['carState'].vCruise != V_CRUISE_UNSET reset_state = reset_state or not v_cruise_initialized - throttle_probs = sm['modelV2'].meta.disengagePredictions.gasPressProbs - throttle_prob = throttle_probs[1] if len(throttle_probs) > 1 else 1.0 - self.allow_throttle = throttle_prob > ALLOW_THROTTLE_THRESHOLD or v_ego <= MIN_ALLOW_THROTTLE_SPEED - - steer_angle_without_offset = sm['carState'].steeringAngleDeg - sm['vehicleParameters'].angleOffsetDeg - if reset_state: self.v_desired_filter.x = v_ego self.output_a_target = np.clip(sm['carState'].aEgo, ACCEL_MIN, ACCEL_MAX) @@ -110,7 +73,10 @@ class LongitudinalPlanner: prev_accel_constraint = not (reset_state or sm['carState'].standstill) self.mpc.set_weights(prev_accel_constraint, personality=sm['selfdriveState'].personality) - self.mpc.set_cur_state(self.v_desired_filter.x, self.output_a_target) + if self.CP.openpilotLongitudinalControl: + self.mpc.set_cur_state(self.v_desired_filter.x, self.output_a_target) + else: + self.mpc.set_cur_state(v_ego, sm['carState'].aEgo) self.mpc.update(sm['radarState'], personality=sm['selfdriveState'].personality) self.v_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.v_solution) @@ -132,17 +98,14 @@ class LongitudinalPlanner: output_a_target_e2e = sm['modelV2'].action.desiredAcceleration output_should_stop_e2e = sm['modelV2'].action.shouldStop - self.a_cruise = get_cruise_accel(sm['selfdriveState'].experimentalMode, v_cruise, v_ego, - self.a_cruise, steer_angle_without_offset, self.CP, self.dt, - accel_coast, self.allow_throttle) + self.a_cruise = get_cruise_accel(v_cruise, v_ego, self.a_cruise, self.dt) cruise_should_stop = should_stop(v_ego, self.a_cruise) output_a_target_e2e = self.accel_boost.update(sm, output_a_target_e2e, output_a_target_mpc, self.a_cruise) candidates = [(output_a_target_mpc, self.mpc.source, output_should_stop_mpc), - (self.a_cruise, LongitudinalPlanSource.cruise, cruise_should_stop)] - if sm['selfdriveState'].experimentalMode: - candidates.append((output_a_target_e2e, LongitudinalPlanSource.e2e, output_should_stop_e2e)) + (self.a_cruise, LongitudinalPlanSource.cruise, cruise_should_stop), + (output_a_target_e2e, LongitudinalPlanSource.e2e, output_should_stop_e2e)] output_a_target, self.mpc.source, _ = min(candidates, key=lambda c: c[0]) self.output_should_stop = any(should_stop for _, _, should_stop in candidates) @@ -172,6 +135,5 @@ class LongitudinalPlanner: longitudinalPlan.accelBoost = float(self.accel_boost.total_boost) longitudinalPlan.shouldStop = bool(self.output_should_stop) longitudinalPlan.allowBrake = True - longitudinalPlan.allowThrottle = bool(self.allow_throttle) pm.send('longitudinalPlan', plan_send) diff --git a/openpilot/selfdrive/controls/plannerd.py b/openpilot/selfdrive/controls/plannerd.py index 110c124166..e2b9725ab4 100755 --- a/openpilot/selfdrive/controls/plannerd.py +++ b/openpilot/selfdrive/controls/plannerd.py @@ -19,7 +19,7 @@ def main(): ldw = LaneDepartureWarning() longitudinal_planner = LongitudinalPlanner(CP) pm = messaging.PubMaster(['longitudinalPlan', 'driverAssistance']) - sm = messaging.SubMaster(['carControl', 'carState', 'controlsState', 'vehicleParameters', 'radarState', 'modelV2', 'selfdriveState'], + sm = messaging.SubMaster(['carControl', 'carState', 'controlsState', 'radarState', 'modelV2', 'selfdriveState'], poll='modelV2') while True: diff --git a/openpilot/selfdrive/controls/tests/test_following_distance.py b/openpilot/selfdrive/controls/tests/test_following_distance.py index 1914769282..ccae879692 100644 --- a/openpilot/selfdrive/controls/tests/test_following_distance.py +++ b/openpilot/selfdrive/controls/tests/test_following_distance.py @@ -13,7 +13,7 @@ def desired_follow_distance(v_ego, v_lead, t_follow=None): t_follow = get_T_FOLLOW() return get_safe_obstacle_distance(v_ego, t_follow) - get_stopped_equivalence_factor(v_lead) -def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personality=0): +def run_following_distance_simulation(v_lead, t_end=100.0, personality=0): man = Maneuver( '', duration=t_end, @@ -22,7 +22,6 @@ def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personalit initial_distance_lead=100, speed_lead_values=[v_lead], breakpoints=[0.], - e2e=e2e, personality=personality, ) valid, output = man.evaluate() @@ -30,8 +29,7 @@ def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personalit return output[-1,2] - output[-1,1] -@parameterized_class(("e2e", "personality", "speed"), itertools.product( - [True, False], # e2e +@parameterized_class(("personality", "speed"), itertools.product( [log.LongitudinalPersonality.relaxed, # personality log.LongitudinalPersonality.standard, log.LongitudinalPersonality.aggressive], @@ -39,9 +37,8 @@ def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personalit class TestFollowingDistance(OpenpilotTestCase): def test_following_distance(self): v_lead = float(self.speed) - simulation_steady_state = run_following_distance_simulation(v_lead, e2e=self.e2e, personality=self.personality) + simulation_steady_state = run_following_distance_simulation(v_lead, personality=self.personality) correct_steady_state = desired_follow_distance(v_lead, v_lead, get_T_FOLLOW(self.personality)) - err_ratio = 0.2 if self.e2e else 0.1 abs_err_margin = 0.5 if v_lead > 0.0 else 1.15 self.assertAlmostEqual(simulation_steady_state, correct_steady_state, - delta=err_ratio * correct_steady_state + abs_err_margin) + delta=0.2 * correct_steady_state + abs_err_margin) diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index e08576409e..3a86b658d9 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -530,7 +530,6 @@ class SelfdriveD: ss.active = self.active ss.state = self.state_machine.state ss.engageable = not self.events.contains(ET.NO_ENTRY) - ss.experimentalMode = self.CP.openpilotLongitudinalControl ss.personality = self.personality ss.alertText1 = self.AM.current_alert.alert_text_1 diff --git a/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py b/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py index 99a7d8d690..f745f0b2d8 100644 --- a/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py +++ b/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py @@ -12,16 +12,12 @@ class Maneuver: self.breakpoints = kwargs.get("breakpoints", [0.0, duration]) self.speed_lead_values = kwargs.get("speed_lead_values", [0.0 for i in range(len(self.breakpoints))]) self.prob_lead_values = kwargs.get("prob_lead_values", [1.0 for i in range(len(self.breakpoints))]) - self.prob_throttle_values = kwargs.get("prob_throttle_values", [1.0 for i in range(len(self.breakpoints))]) self.cruise_values = kwargs.get("cruise_values", [50.0 for i in range(len(self.breakpoints))]) - self.pitch_values = kwargs.get("pitch_values", [0.0 for i in range(len(self.breakpoints))]) self.only_lead2 = kwargs.get("only_lead2", False) self.only_radar = kwargs.get("only_radar", False) self.ensure_start = kwargs.get("ensure_start", False) - self.ensure_slowdown = kwargs.get("ensure_slowdown", False) self.enabled = kwargs.get("enabled", True) - self.e2e = kwargs.get("e2e", False) self.personality = kwargs.get("personality", 0) self.force_decel = kwargs.get("force_decel", False) @@ -36,7 +32,6 @@ class Maneuver: enabled=self.enabled, only_lead2=self.only_lead2, only_radar=self.only_radar, - e2e=self.e2e, personality=self.personality, force_decel=self.force_decel, ) @@ -48,9 +43,7 @@ class Maneuver: speed_lead = np.interp(plant.current_time, self.breakpoints, self.speed_lead_values) prob_lead = np.interp(plant.current_time, self.breakpoints, self.prob_lead_values) cruise = np.interp(plant.current_time, self.breakpoints, self.cruise_values) - pitch = np.interp(plant.current_time, self.breakpoints, self.pitch_values) - prob_throttle = np.interp(plant.current_time, self.breakpoints, self.prob_throttle_values) - log = plant.step(speed_lead, prob_lead, cruise, pitch, prob_throttle) + log = plant.step(speed_lead, prob_lead, cruise) d_rel = log['distance_lead'] - log['distance'] if self.lead_relevancy else 200. v_rel = speed_lead - log['speed'] if self.lead_relevancy else 0. @@ -77,10 +70,6 @@ class Maneuver: else: not_starting_t = 0.0 - if self.ensure_slowdown and log['speed'] > 5.5: - print('LongitudinalPlanner not slowing down!') - valid = False - if self.force_decel and log['speed'] > 1e-1 and log['acceleration'] > -0.04: print('Not stopping with force decel') valid = False diff --git a/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py b/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py index 036aaa2ed3..8ca3cc2f74 100755 --- a/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py +++ b/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py @@ -15,7 +15,7 @@ class Plant: messaging_initialized = False def __init__(self, lead_relevancy=False, speed=0.0, distance_lead=2.0, - enabled=True, only_lead2=False, only_radar=False, e2e=False, personality=0, force_decel=False): + enabled=True, only_lead2=False, only_radar=False, personality=0, force_decel=False): self.rate = 1. / DT_MDL if not Plant.messaging_initialized: @@ -39,7 +39,6 @@ class Plant: self.enabled = enabled self.only_lead2 = only_lead2 self.only_radar = only_radar - self.e2e = e2e self.personality = personality self.force_decel = force_decel @@ -57,15 +56,13 @@ class Plant: def current_time(self): return float(self.rk.frame) / self.rate - def step(self, v_lead=0.0, prob_lead=1.0, v_cruise=50., pitch=0.0, prob_throttle=1.0): + def step(self, v_lead=0.0, prob_lead=1.0, v_cruise=50.): # ******** publish a fake model going straight and fake calibration ******** # note that this is worst case for MPC, since model will delay long mpc by one time step radar = messaging.new_message('radarState') control = messaging.new_message('controlsState') ss = messaging.new_message('selfdriveState') car_state = messaging.new_message('carState') - lp = messaging.new_message('vehicleParameters') - car_control = messaging.new_message('carControl') model = messaging.new_message('modelV2') a_lead = (v_lead - self.v_lead_prev)/self.ts self.v_lead_prev = v_lead @@ -103,7 +100,7 @@ class Plant: # Simulate model predicting slightly faster speed # this is to ensure lead policy is effective when model - # does not predict slowdown in e2e mode + # does not predict slowdown position = log.XYZTData.new_message() position.x = [float(x) for x in (self.speed + 0.5) * np.array(ModelConstants.T_IDXS)] model.modelV2.position = position @@ -115,24 +112,19 @@ class Plant: acceleration = log.XYZTData.new_message() acceleration.x = [float(x) for x in np.zeros_like(ModelConstants.T_IDXS)] model.modelV2.acceleration = acceleration - model.modelV2.meta.disengagePredictions.gasPressProbs = [float(prob_throttle) for _ in range(6)] control.controlsState.longControlState = LongCtrlState.pid if self.enabled else LongCtrlState.off - ss.selfdriveState.experimentalMode = self.e2e ss.selfdriveState.personality = self.personality control.controlsState.forceDecel = self.force_decel car_state.carState.vEgo = float(self.speed) car_state.carState.standstill = bool(self.speed < 0.01) car_state.carState.vCruise = float(v_cruise * 3.6) - car_control.carControl.orientationNED = [0., float(pitch), 0.] # ******** get controlsState messages for plotting *** sm = {'radarState': radar.radarState, 'carState': car_state.carState, - 'carControl': car_control.carControl, 'controlsState': control.controlsState, 'selfdriveState': ss.selfdriveState, - 'vehicleParameters': lp.vehicleParameters, 'modelV2': model.modelV2} self.planner.update(sm) self.acceleration = self.planner.output_a_target diff --git a/openpilot/selfdrive/test/longitudinal_maneuvers/test_longitudinal.py b/openpilot/selfdrive/test/longitudinal_maneuvers/test_longitudinal.py index c8694b6ac2..226c1fa7ef 100644 --- a/openpilot/selfdrive/test/longitudinal_maneuvers/test_longitudinal.py +++ b/openpilot/selfdrive/test/longitudinal_maneuvers/test_longitudinal.py @@ -1,4 +1,3 @@ -import itertools from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized_class @@ -83,32 +82,6 @@ def create_maneuvers(kwargs): breakpoints=[0.0, 2., 2.01], **kwargs, ), - Maneuver( - "approach stopped car at 20m/s, with prob_throttle_values and pitch = -0.1", - duration=30., - initial_speed=20., - lead_relevancy=True, - initial_distance_lead=120., - speed_lead_values=[0.0, 0., 0.], - prob_throttle_values=[1., 0., 0.], - cruise_values=[20., 20., 20.], - pitch_values=[0., -0.1, -0.1], - breakpoints=[0.0, 2., 2.01], - **kwargs, - ), - Maneuver( - "approach stopped car at 20m/s, with prob_throttle_values and pitch = +0.1", - duration=30., - initial_speed=20., - lead_relevancy=True, - initial_distance_lead=120., - speed_lead_values=[0.0, 0., 0.], - prob_throttle_values=[1., 0., 0.], - cruise_values=[20., 20., 20.], - pitch_values=[0., 0.1, 0.1], - breakpoints=[0.0, 2., 2.01], - **kwargs, - ), Maneuver( "approach slower cut-in car at 20m/s", duration=20., @@ -152,19 +125,6 @@ def create_maneuvers(kwargs): **kwargs, ), ] - if not kwargs['e2e']: - maneuvers.append(Maneuver( - "slow to 5m/s with allow_throttle = False and pitch = +0.1", - duration=30., - initial_speed=20., - lead_relevancy=False, - prob_throttle_values=[1., 0., 0.], - cruise_values=[20., 20., 20.], - pitch_values=[0., 0.1, 0.1], - breakpoints=[0.0, 2., 2.01], - ensure_slowdown=True, - **kwargs, - )) if not kwargs['force_decel']: # controls relies on planner commanding to move for stock-ACC resume spamming maneuvers.append(Maneuver( @@ -181,14 +141,13 @@ def create_maneuvers(kwargs): return maneuvers -@parameterized_class(("e2e", "force_decel"), itertools.product([True, False], repeat=2)) +@parameterized_class("force_decel", [True, False]) class TestLongitudinalControl(OpenpilotTestCase): - e2e: bool force_decel: bool def test_maneuver(self, subtests): - for maneuver in create_maneuvers({"e2e": self.e2e, "force_decel": self.force_decel}): - with subtests.test(title=maneuver.title, e2e=maneuver.e2e, force_decel=maneuver.force_decel): - print(maneuver.title, f'in {"e2e" if maneuver.e2e else "acc"} mode') + for maneuver in create_maneuvers({"force_decel": self.force_decel}): + with subtests.test(title=maneuver.title, force_decel=maneuver.force_decel): + print(maneuver.title) valid, _ = maneuver.evaluate() assert valid diff --git a/openpilot/selfdrive/test/process_replay/migration.py b/openpilot/selfdrive/test/process_replay/migration.py index a77d3b7044..1e59d39645 100644 --- a/openpilot/selfdrive/test/process_replay/migration.py +++ b/openpilot/selfdrive/test/process_replay/migration.py @@ -230,8 +230,7 @@ def migrate_controlsState(msgs): m.logMonoTime = msg.logMonoTime ss = m.selfdriveState for field in ("enabled", "active", "state", "engageable", "alertText1", "alertText2", - "alertStatus", "alertSize", "alertType", "experimentalMode", - "personality"): + "alertStatus", "alertSize", "alertType", "personality"): setattr(ss, field, getattr(msg.controlsState.deprecated, field)) add_ops.append(as_reader(m)) return [], add_ops, [] diff --git a/openpilot/selfdrive/test/process_replay/process_replay.py b/openpilot/selfdrive/test/process_replay/process_replay.py index 03de7aee3f..34445b9a8f 100755 --- a/openpilot/selfdrive/test/process_replay/process_replay.py +++ b/openpilot/selfdrive/test/process_replay/process_replay.py @@ -479,7 +479,7 @@ CONFIGS = [ ), ProcessConfig( proc_name="plannerd", - pubs=["modelV2", "carControl", "carState", "controlsState", "vehicleParameters", "radarState", "selfdriveState"], + pubs=["modelV2", "carControl", "carState", "controlsState", "radarState", "selfdriveState"], subs=["longitudinalPlan", "driverAssistance"], ignore=["logMonoTime", "longitudinalPlan.processingDelay", "longitudinalPlan.solverExecutionTime"], init_callback=get_car_params_callback, diff --git a/openpilot/selfdrive/ui/layouts/settings/firehose.py b/openpilot/selfdrive/ui/layouts/settings/firehose.py index f28f4e3386..fd587e21b3 100644 --- a/openpilot/selfdrive/ui/layouts/settings/firehose.py +++ b/openpilot/selfdrive/ui/layouts/settings/firehose.py @@ -11,7 +11,7 @@ TITLE = tr_noop("Firehose Mode") DESCRIPTION = tr_noop( "openpilot learns to drive by watching humans, like you, drive.\n\n" + "Firehose Mode allows you to maximize your training data uploads to improve " - + "openpilot's driving models. More data means bigger models, which means better Experimental Mode." + + "openpilot's driving models. More data means bigger models, which means better driving." ) INSTRUCTIONS = tr_noop( "For maximum effectiveness, bring your device inside and connect to a good USB-C adapter and Wi-Fi weekly.\n\n" diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/firehose.py b/openpilot/selfdrive/ui/mici/layouts/settings/firehose.py index 66393de679..c621d44e66 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/firehose.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/firehose.py @@ -21,7 +21,7 @@ TITLE = tr_noop("Firehose Mode") DESCRIPTION = tr_noop( "openpilot learns to drive by watching humans, like you, drive.\n\n" + "Firehose Mode allows you to maximize your training data uploads to improve " - + "openpilot's driving models. More data means bigger models, which means better Experimental Mode." + + "openpilot's driving models. More data means bigger models, which means better driving." ) INSTRUCTIONS_INTRO = tr_noop( "For maximum effectiveness, bring your device inside and connect to a good USB-C adapter and Wi-Fi weekly.\n\n" diff --git a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py index 1a30868426..bddd1bd5d7 100644 --- a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -280,7 +280,7 @@ class AugmentedRoadView(CameraView): super().close() def _switch_stream_if_needed(self, sm): - if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams: + if sm['carParams'].openpilotLongitudinalControl and WIDE_CAM in self.available_streams: v_ego = sm['carState'].vEgo if v_ego < WIDE_CAM_MAX_SPEED: target = WIDE_CAM diff --git a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py index e5dcfc44bf..754c86d358 100644 --- a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py @@ -20,17 +20,12 @@ MAX_DRAW_DISTANCE = 100.0 LEAD_BAR_LENGTH = 12.0 # px LEAD_BAR_WIDTH = 1.8 # m -THROTTLE_COLORS = [ - rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4) - rl.Color(114, 255, 92, 89), # HSLF(112/360, 1.0, 0.68, 0.35) - rl.Color(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0) -] - -NO_THROTTLE_COLORS = [ - rl.Color(242, 242, 242, 102), # HSLF(148/360, 0.0, 0.95, 0.4) - rl.Color(242, 242, 242, 89), # HSLF(112/360, 0.0, 0.95, 0.35) - rl.Color(242, 242, 242, 0), # HSLF(112/360, 0.0, 0.95, 0.0) -] +STOCK_PATH_GRADIENT = Gradient( + start=(0.0, 1.0), + end=(0.0, 0.0), + colors=[rl.Color(13, 248, 122, 102), rl.Color(114, 255, 92, 89), rl.Color(114, 255, 92, 0)], + stops=[0.0, 0.5, 1.0], +) LANE_LINE_COLORS = { UIStatus.DISENGAGED: rl.Color(200, 200, 200, 255), @@ -57,9 +52,6 @@ class ModelRenderer(Widget): def __init__(self): super().__init__() self._longitudinal_control = False - self._experimental_mode = False - self._blend_filter = FirstOrderFilter(1.0, 0.25, 1 / gui_app.target_fps) - self._prev_allow_throttle = True self._lane_line_probs = np.zeros(4, dtype=np.float32) self._road_edge_stds = np.zeros(2, dtype=np.float32) self._lead_vehicles = [LeadVehicle(), LeadVehicle()] @@ -71,18 +63,14 @@ class ModelRenderer(Widget): self._road_edges = [ModelPoints() for _ in range(2)] self._acceleration_x = np.empty((0,), dtype=np.float32) - self._acceleration_x_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) - self._acceleration_x_filter2 = FirstOrderFilter(0.0, 1, 1 / gui_app.target_fps) - self._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps) - self._ll_color_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) # 3x3 car space -> rect-origin space (draw methods add rect.x/y) self._car_space_transform = np.zeros((3, 3), dtype=np.float32) self._transform_dirty = True self._clip_region = None - self._exp_gradient = Gradient( + self._path_gradient = Gradient( start=(0.0, 1.0), # Bottom of path end=(0.0, 0.0), # Top of path colors=[], @@ -114,8 +102,6 @@ class ModelRenderer(Widget): ) # Update state - self._experimental_mode = sm['selfdriveState'].experimentalMode - extrinsics_calibration = sm['extrinsicsCalibration'] self._path_offset_z = extrinsics_calibration.height[0] if extrinsics_calibration.height else HEIGHT_INIT[0] @@ -143,7 +129,7 @@ class ModelRenderer(Widget): # Draw elements (hide when disengaged) if ui_state.status != UIStatus.DISENGAGED: self._draw_lane_lines() - self._draw_path(sm) + self._draw_path() if render_lead_indicator: self._update_leads(sm) @@ -228,27 +214,15 @@ class ModelRenderer(Widget): lead_d = lead.dRel * 2.0 max_distance = np.clip(lead_d - min(lead_d * 0.35, 10.0), 0.0, max_distance) - soon_acceleration = self._acceleration_x[len(self._acceleration_x) // 4] if len(self._acceleration_x) > 0 else 0 - self._acceleration_x_filter.update(soon_acceleration) - self._acceleration_x_filter2.update(soon_acceleration) - - # make path width wider/thinner when initially braking/accelerating - if self._experimental_mode and False: - high_pass_acceleration = self._acceleration_x_filter.x - self._acceleration_x_filter2.x - y_off = np.interp(high_pass_acceleration, [-1, 0, 1], [0.9 * 2, 0.9, 0.9 / 2]) - else: - y_off = 0.9 - max_idx = self._get_path_length_idx(path_x_array, max_distance) self._path.projected_points = self._map_line_to_polygon( - self._path.raw_points, y_off, self._path_offset_z, max_idx, allow_invert=False + self._path.raw_points, 0.9, self._path_offset_z, max_idx, allow_invert=False ) - self._update_experimental_gradient() + self._update_path_gradient() - def _update_experimental_gradient(self): - """Pre-calculate experimental mode gradient colors""" - if not self._experimental_mode: + def _update_path_gradient(self): + if not self._longitudinal_control: return path_pts = self._path.projected_points + np.array([self._rect.x, self._rect.y], dtype=np.float32) @@ -285,8 +259,8 @@ class ModelRenderer(Widget): i += 1 + (1 if (i + 2) < max_len else 0) # Store the gradient in the path object - self._exp_gradient.colors = segment_colors - self._exp_gradient.stops = gradient_stops + self._path_gradient.colors = segment_colors + self._path_gradient.stops = gradient_stops def _get_ll_color(self, prob: float, adjacent: bool, left: bool): alpha = np.clip(prob, 0.0, 0.7) @@ -330,39 +304,20 @@ class ModelRenderer(Widget): color = self._get_ll_color(float(1.0 - self._road_edge_stds[i]), float(self._lane_line_probs[i + 1]) < 0.25, i == 0) draw_polygon(self._rect, road_edge.projected_points + offset, color) - def _draw_path(self, sm): - """Draw path with dynamic coloring based on mode and throttle state.""" + def _draw_path(self): if not self._path.projected_points.size: return - allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control - self._blend_filter.update(int(allow_throttle)) - path_pts = self._path.projected_points + np.array([self._rect.x, self._rect.y], dtype=np.float32) - if self._experimental_mode: - # Draw with acceleration coloring - if ui_state.status == UIStatus.DISENGAGED: - draw_polygon(self._rect, path_pts, rl.Color(0, 0, 0, 90)) - elif len(self._exp_gradient.colors) > 1: - draw_polygon(self._rect, path_pts, gradient=self._exp_gradient) - else: - draw_polygon(self._rect, path_pts, rl.Color(255, 255, 255, 30)) + if ui_state.status == UIStatus.DISENGAGED: + draw_polygon(self._rect, path_pts, rl.Color(0, 0, 0, 90)) + elif not self._longitudinal_control: + draw_polygon(self._rect, path_pts, gradient=STOCK_PATH_GRADIENT) + elif len(self._path_gradient.colors) > 1: + draw_polygon(self._rect, path_pts, gradient=self._path_gradient) else: - # Blend throttle/no throttle colors based on transition - blend_factor = round(self._blend_filter.x * 100) / 100 - blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor) - gradient = Gradient( - start=(0.0, 1.0), # Bottom of path - end=(0.0, 0.0), # Top of path - colors=blended_colors, - stops=[0.0, 0.5, 1.0], - ) - - if ui_state.status == UIStatus.DISENGAGED: - draw_polygon(self._rect, path_pts, rl.Color(0, 0, 0, 90)) - else: - draw_polygon(self._rect, path_pts, gradient=gradient) + draw_polygon(self._rect, path_pts, rl.Color(255, 255, 255, 30)) def _draw_lead_indicator(self): offset = np.array([self._rect.x, self._rect.y], dtype=np.float32) @@ -452,18 +407,3 @@ class ModelRenderer(Widget): int(rgb[2] * 255), int(a * 255) ) - - @staticmethod - def _blend_colors(begin_colors, end_colors, t): - if t >= 1.0: - return end_colors - if t <= 0.0: - return begin_colors - - inv_t = 1.0 - t - return [rl.Color( - int(inv_t * start.r + t * end.r), - int(inv_t * start.g + t * end.g), - int(inv_t * start.b + t * end.b), - int(inv_t * start.a + t * end.a) - ) for start, end in zip(begin_colors, end_colors, strict=True)] diff --git a/openpilot/selfdrive/ui/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/onroad/augmented_road_view.py index 22d7d61ff3..58d2a55830 100644 --- a/openpilot/selfdrive/ui/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/onroad/augmented_road_view.py @@ -110,7 +110,7 @@ class AugmentedRoadView(CameraView): rl.draw_rectangle_rounded_lines_ex(border_rect, border_roundness, 10, UI_BORDER_SIZE, border_color) def _switch_stream_if_needed(self, sm): - if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams: + if sm['carParams'].openpilotLongitudinalControl and WIDE_CAM in self.available_streams: v_ego = sm['carState'].vEgo if v_ego < WIDE_CAM_MAX_SPEED: target = WIDE_CAM diff --git a/openpilot/selfdrive/ui/onroad/model_renderer.py b/openpilot/selfdrive/ui/onroad/model_renderer.py index 18fe45d8e1..6dc7dc51b0 100644 --- a/openpilot/selfdrive/ui/onroad/model_renderer.py +++ b/openpilot/selfdrive/ui/onroad/model_renderer.py @@ -4,11 +4,9 @@ import pyray as rl from openpilot.cereal import messaging from opendbc.car.structs import car from dataclasses import dataclass, field -from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient from openpilot.system.ui.widgets import Widget @@ -16,17 +14,12 @@ CLIP_MARGIN = 500 MIN_DRAW_DISTANCE = 10.0 MAX_DRAW_DISTANCE = 100.0 -THROTTLE_COLORS = [ - rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4) - rl.Color(114, 255, 92, 89), # HSLF(112/360, 1.0, 0.68, 0.35) - rl.Color(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0) -] - -NO_THROTTLE_COLORS = [ - rl.Color(242, 242, 242, 102), # HSLF(148/360, 0.0, 0.95, 0.4) - rl.Color(242, 242, 242, 89), # HSLF(112/360, 0.0, 0.95, 0.35) - rl.Color(242, 242, 242, 0), # HSLF(112/360, 0.0, 0.95, 0.0) -] +STOCK_PATH_GRADIENT = Gradient( + start=(0.0, 1.0), + end=(0.0, 0.0), + colors=[rl.Color(13, 248, 122, 102), rl.Color(114, 255, 92, 89), rl.Color(114, 255, 92, 0)], + stops=[0.0, 0.5, 1.0], +) @dataclass @@ -46,9 +39,6 @@ class ModelRenderer(Widget): def __init__(self): super().__init__() self._longitudinal_control = False - self._experimental_mode = False - self._blend_filter = FirstOrderFilter(1.0, 0.25, 1 / gui_app.target_fps) - self._prev_allow_throttle = True self._lane_line_probs = np.zeros(4, dtype=np.float32) self._road_edge_stds = np.zeros(2, dtype=np.float32) self._lead_vehicles = [LeadVehicle(), LeadVehicle()] @@ -65,7 +55,7 @@ class ModelRenderer(Widget): self._transform_dirty = True self._clip_region = None - self._exp_gradient = Gradient( + self._path_gradient = Gradient( start=(0.0, 1.0), # Bottom of path end=(0.0, 0.0), # Top of path colors=[], @@ -95,8 +85,6 @@ class ModelRenderer(Widget): ) # Update state - self._experimental_mode = sm['selfdriveState'].experimentalMode - extrinsics_calibration = sm['extrinsicsCalibration'] self._path_offset_z = extrinsics_calibration.height[0] if extrinsics_calibration.height else HEIGHT_INIT[0] @@ -125,7 +113,7 @@ class ModelRenderer(Widget): # Draw elements self._draw_lane_lines() - self._draw_path(sm) + self._draw_path() if render_lead_indicator and radar_state: self._draw_lead_indicator() @@ -185,11 +173,10 @@ class ModelRenderer(Widget): self._path.raw_points, 0.9, self._path_offset_z, max_idx, max_distance, allow_invert=False ) - self._update_experimental_gradient() + self._update_path_gradient() - def _update_experimental_gradient(self): - """Pre-calculate experimental mode gradient colors""" - if not self._experimental_mode: + def _update_path_gradient(self): + if not self._longitudinal_control: return max_len = min(len(self._path.projected_points) // 2, len(self._acceleration_x)) @@ -225,7 +212,7 @@ class ModelRenderer(Widget): i += 1 + (1 if (i + 2) < max_len else 0) # Store the gradient in the path object - self._exp_gradient = Gradient( + self._path_gradient = Gradient( start=(0.0, 1.0), # Bottom of path end=(0.0, 0.0), # Top of path colors=segment_colors, @@ -274,31 +261,16 @@ class ModelRenderer(Widget): color = rl.Color(255, 0, 0, int(alpha * 255)) draw_polygon(self._rect, road_edge.projected_points, color) - def _draw_path(self, sm): - """Draw path with dynamic coloring based on mode and throttle state.""" + def _draw_path(self): if not self._path.projected_points.size: return - allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control - self._blend_filter.update(int(allow_throttle)) - - if self._experimental_mode: - # Draw with acceleration coloring - if len(self._exp_gradient.colors) > 1: - draw_polygon(self._rect, self._path.projected_points, gradient=self._exp_gradient) - else: - draw_polygon(self._rect, self._path.projected_points, rl.Color(255, 255, 255, 30)) + if not self._longitudinal_control: + draw_polygon(self._rect, self._path.projected_points, gradient=STOCK_PATH_GRADIENT) + elif len(self._path_gradient.colors) > 1: + draw_polygon(self._rect, self._path.projected_points, gradient=self._path_gradient) else: - # Blend throttle/no throttle colors based on transition - blend_factor = round(self._blend_filter.x * 100) / 100 - blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor) - gradient = Gradient( - start=(0.0, 1.0), # Bottom of path - end=(0.0, 0.0), # Top of path - colors=blended_colors, - stops=[0.0, 0.5, 1.0], - ) - draw_polygon(self._rect, self._path.projected_points, gradient=gradient) + draw_polygon(self._rect, self._path.projected_points, rl.Color(255, 255, 255, 30)) def _draw_lead_indicator(self): # Draw lead vehicles if available @@ -419,18 +391,3 @@ class ModelRenderer(Widget): int(rgb[2] * 255), int(a * 255) ) - - @staticmethod - def _blend_colors(begin_colors, end_colors, t): - if t >= 1.0: - return end_colors - if t <= 0.0: - return begin_colors - - inv_t = 1.0 - t - return [rl.Color( - int(inv_t * start.r + t * end.r), - int(inv_t * start.g + t * end.g), - int(inv_t * start.b + t * end.b), - int(inv_t * start.a + t * end.a) - ) for start, end in zip(begin_colors, end_colors, strict=True)] diff --git a/openpilot/selfdrive/ui/tests/diff/replay_script.py b/openpilot/selfdrive/ui/tests/diff/replay_script.py index 962d409344..9a9dfbbc8e 100644 --- a/openpilot/selfdrive/ui/tests/diff/replay_script.py +++ b/openpilot/selfdrive/ui/tests/diff/replay_script.py @@ -478,7 +478,6 @@ def build_tizi_script(pm: PubMaster, main_layout, script: Script) -> None: # === Settings - Toggles === script.click(278, 600) - script.click(1200, 280) # expand experimental mode description # === Settings - Software === script.setup(lambda: setup_update_available(False), wait_after=0) # start with no update available diff --git a/openpilot/tools/longitudinal_maneuvers/maneuversd.py b/openpilot/tools/longitudinal_maneuvers/maneuversd.py index cd5ebe76bc..4b2f8386d3 100755 --- a/openpilot/tools/longitudinal_maneuvers/maneuversd.py +++ b/openpilot/tools/longitudinal_maneuvers/maneuversd.py @@ -181,7 +181,6 @@ def main(): longitudinalPlan.shouldStop = should_stop(v_ego, accel) longitudinalPlan.allowBrake = True - longitudinalPlan.allowThrottle = True longitudinalPlan.hasLead = True longitudinalPlan.speeds = [0.2] # triggers carControl.cruiseControl.resume in controlsd diff --git a/openpilot/tools/longitudinal_maneuvers/mpc_longitudinal_tuning_report.py b/openpilot/tools/longitudinal_maneuvers/mpc_longitudinal_tuning_report.py index 6b711a0847..4ad15e66dd 100644 --- a/openpilot/tools/longitudinal_maneuvers/mpc_longitudinal_tuning_report.py +++ b/openpilot/tools/longitudinal_maneuvers/mpc_longitudinal_tuning_report.py @@ -25,7 +25,7 @@ def get_html_from_results(results, labels, AXIS): plt.close(fig) return fig_buffer.getvalue() + '
' -def generate_mpc_tuning_report(e2e=False): +def generate_mpc_tuning_report(): htmls = [] results = {} @@ -42,7 +42,6 @@ def generate_mpc_tuning_report(e2e=False): cruise_values=[100, 100], prob_lead_values=[1.0, 1.0], breakpoints=[1., 11], - e2e=e2e, ) valid, results[lead_accel] = man.evaluate() labels.append(f'{lead_accel} m/s^2 lead acceleration') @@ -64,7 +63,6 @@ def generate_mpc_tuning_report(e2e=False): initial_distance_lead=140., speed_lead_values=[0.0, 0.], breakpoints=[0., 30.], - e2e=e2e, ) valid, results[speed] = man.evaluate() labels.append(f'{speed} m/s approach speed') @@ -87,7 +85,6 @@ def generate_mpc_tuning_report(e2e=False): initial_distance_lead=desired_follow_distance(speed, speed), speed_lead_values=[speed, speed, speed - oscil, speed + oscil, speed - oscil, speed + oscil, speed - oscil], breakpoints=[0.,2., 5, 8, 15, 18, 25.], - e2e=e2e, ) valid, results[oscil] = man.evaluate() labels.append(f'{oscil} m/s oscillation size') @@ -115,7 +112,6 @@ def generate_mpc_tuning_report(e2e=False): initial_distance_lead=desired_follow_distance(speed, speed), speed_lead_values=lead_speeds, breakpoints=bps, - e2e=e2e, ) valid, results[oscil] = man.evaluate() labels.append(f'{oscil} m/s oscillation size') @@ -138,7 +134,6 @@ def generate_mpc_tuning_report(e2e=False): initial_distance_lead=distance, speed_lead_values=[30.0], breakpoints=[0.], - e2e=e2e, ) valid, results[distance] = man.evaluate() labels.append(f'{distance} m initial distance') @@ -160,7 +155,6 @@ def generate_mpc_tuning_report(e2e=False): initial_distance_lead=distance, speed_lead_values=[20.0], breakpoints=[0.], - e2e=e2e, ) valid, results[distance] = man.evaluate() labels.append(f'{distance} m initial distance') @@ -183,7 +177,6 @@ def generate_mpc_tuning_report(e2e=False): initial_distance_lead=60.0, speed_lead_values=[30.0, 30.0, 0.0], breakpoints=[0., 5., 5 + stop_time], - e2e=e2e, ) valid, results[stop_time] = man.evaluate() labels.append(f'{stop_time} seconds stop time') @@ -207,7 +200,6 @@ def generate_mpc_tuning_report(e2e=False): speed_lead_values=[speed, speed, speed], prob_lead_values=[0.0, 0.0, 1.0], breakpoints=[0., 5.0, 5.01], - e2e=e2e, ) valid, results[speed] = man.evaluate() labels.append(f'{speed} m/s speed') @@ -230,7 +222,6 @@ def generate_mpc_tuning_report(e2e=False): speed_lead_values=[0.0, 0.0, speed], prob_lead_values=[1.0, 1.0, 1.0], breakpoints=[0., 1.0, speed/2], - e2e=e2e, ) valid, results[speed] = man.evaluate() labels.append(f'{speed} m/s speed') @@ -254,7 +245,6 @@ def generate_mpc_tuning_report(e2e=False): cruise_values=[0.0, speed], prob_lead_values=[0.0, 0.0], breakpoints=[1., 1.01], - e2e=e2e, ) valid, results[speed] = man.evaluate() labels.append(f'{speed} m/s speed') @@ -278,7 +268,6 @@ def generate_mpc_tuning_report(e2e=False): cruise_values=[speed, 10.0], prob_lead_values=[0.0, 0.0], breakpoints=[1., 1.01], - e2e=e2e, ) valid, results[speed] = man.evaluate() labels.append(f'{speed} m/s speed') @@ -290,14 +279,13 @@ def generate_mpc_tuning_report(e2e=False): return htmls if __name__ == '__main__': - e2e = '--e2e' in sys.argv file_name = 'long_mpc_tune_report.html' for arg in sys.argv[1:]: if not arg.startswith('-'): file_name = arg break - htmls = generate_mpc_tuning_report(e2e=e2e) + htmls = generate_mpc_tuning_report() with open(file_name, 'w') as f: f.write(markdown.markdown('# MPC longitudinal tuning report')) diff --git a/openpilot/tools/replay/logreader.cc b/openpilot/tools/replay/logreader.cc index a626d4d442..cafd862e16 100644 --- a/openpilot/tools/replay/logreader.cc +++ b/openpilot/tools/replay/logreader.cc @@ -142,7 +142,6 @@ void LogReader::migrateOldEvents() { new_state.setAlertType(old_dep.getAlertType()); new_state.setEnabled(old_dep.getEnabled()); new_state.setEngageable(old_dep.getEngageable()); - new_state.setExperimentalMode(old_dep.getExperimentalMode()); new_state.setPersonality(old_dep.getPersonality()); new_state.setState(old_dep.getState()); From 27c132984df60e9ef42ebd1a805738b8c851356f Mon Sep 17 00:00:00 2001 From: royjr Date: Fri, 16 Oct 2026 12:56:24 -0400 Subject: [PATCH 255/270] bug: fix duplicate MADS no-entry alerts (#1949) * alert-dedup * Update selfdrived.py * Update selfdrived.py * one liner --------- Co-authored-by: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> --- openpilot/selfdrive/selfdrived/selfdrived.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 2354717a5c..4b549f53cc 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -577,7 +577,7 @@ class SelfdriveD(CruiseHelper): clear_event_types = set() if ET.WARNING not in self.state_machine.current_alert_types: clear_event_types.add(ET.WARNING) - if self.enabled: + if self.enabled or (ET.NO_ENTRY not in (c := self.state_machine.current_alert_types) and (ET.ENABLE in c or ET.USER_DISABLE in c)): clear_event_types.add(ET.NO_ENTRY) pers = LONGITUDINAL_PERSONALITY_MAP[self.personality] From 35e78a9d9518855ec0dacec919e719624a28131f Mon Sep 17 00:00:00 2001 From: Adeeb Shihadeh Date: Fri, 16 Oct 2026 19:19:47 -0700 Subject: [PATCH 256/270] show offroad alert for ignored uploads (#39104) --- openpilot/common/params_keys.h | 1 + openpilot/selfdrive/selfdrived/alerts_offroad.json | 4 ++++ openpilot/selfdrive/ui/lib/prime_state.py | 1 + 3 files changed, 6 insertions(+) diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 459189646d..fa105de62c 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -108,6 +108,7 @@ inline static std::unordered_map keys = { {"Offroad_Recalibration", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_TemperatureTooHigh", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_UnregisteredHardware", {CLEAR_ON_MANAGER_START, JSON}}, + {"Offroad_UploadsIgnored", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_UpdateFailed", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_DriverMonitoringUncertain", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"OnroadCycleRequested", {CLEAR_ON_MANAGER_START, BOOL}}, diff --git a/openpilot/selfdrive/selfdrived/alerts_offroad.json b/openpilot/selfdrive/selfdrived/alerts_offroad.json index 052cc21f92..64119f5607 100644 --- a/openpilot/selfdrive/selfdrived/alerts_offroad.json +++ b/openpilot/selfdrive/selfdrived/alerts_offroad.json @@ -49,6 +49,10 @@ "text": "Failed to register with comma.ai backend. It will not connect or upload to comma.ai servers, and receives no support from comma.ai. If this is a device purchased at comma.ai/shop, open a ticket at https://comma.ai/support.", "severity": 1 }, + "Offroad_UploadsIgnored": { + "text": "Uploads ignored due to a potential detected safety policy violation. Contact comma.ai/support if you believe this was made in error.", + "severity": 1 + }, "Offroad_CarUnrecognized": { "text": "openpilot was unable to identify your car. Your car is either unsupported or its ECUs are not recognized. Please submit a pull request to add the firmware versions to the proper vehicle. Need help? Join discord.comma.ai.", "severity": 0 diff --git a/openpilot/selfdrive/ui/lib/prime_state.py b/openpilot/selfdrive/ui/lib/prime_state.py index 6b37675c9b..88de7c21ff 100644 --- a/openpilot/selfdrive/ui/lib/prime_state.py +++ b/openpilot/selfdrive/ui/lib/prime_state.py @@ -68,6 +68,7 @@ class PrimeState: response = api_get(f"v1.1/devices/{dongle_id}", timeout=self.API_TIMEOUT, access_token=identity_token, session=self._session) if response.status_code == 200: data = response.json() + set_offroad_alert("Offroad_UploadsIgnored", bool(data.get("ignore_uploads"))) is_paired = data.get("is_paired", False) prime_type = data.get("prime_type", 0) From 4ff90ff8d074455141c8b5918131e59c9f6c2bf7 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:31:04 -0400 Subject: [PATCH 257/270] modeld_v2: compile models to plain gpu memory artifact (#2050) --- .github/workflows/build-default-models.yaml | 76 +---- .../build-single-tinygrad-model.yaml | 16 +- .../action.yml | 26 +- .github/workflows/sunnypilot-build-model.yaml | 32 +- .../workflows/sunnypilot-build-prebuilt.yaml | 8 +- .github/workflows/test_models.yml | 34 +- .../sunnypilot/modeld_v2/compile_modeld.py | 112 +++--- .../sunnypilot/modeld_v2/compile_modeld_v2.py | 58 +++- openpilot/sunnypilot/modeld_v2/helpers.py | 323 +++++++++++++----- .../sunnypilot/modeld_v2/model_adapters.py | 7 +- openpilot/sunnypilot/modeld_v2/modeld.py | 11 +- .../modeld_v2/plain_artifact_compiler.py | 90 +++++ .../sunnypilot/modeld_v2/tests/helpers.py | 5 +- .../tests/test_combined_pkl_loader.py | 4 +- .../modeld_v2/tests/test_compile_modeld.py | 36 +- .../modeld_v2/tests/test_fallback.py | 13 +- .../sunnypilot/modeld_v2/tests/test_models.py | 75 +--- openpilot/sunnypilot/modeld_v2/warmup.py | 42 +++ openpilot/sunnypilot/models/fetcher.py | 32 +- openpilot/sunnypilot/models/helpers.py | 22 +- openpilot/sunnypilot/models/manager.py | 92 +---- .../models/tests/test_manager_download.py | 180 ++-------- openpilot/system/manager/process_config.py | 1 + release/ci/model_generator.py | 49 +-- release/ci/upload_default_model.py | 2 - tinygrad_repo | 2 +- 26 files changed, 627 insertions(+), 721 deletions(-) rename .github/workflows/{download-hf-model-chunks => download-hf-model}/action.yml (74%) create mode 100644 openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py create mode 100755 openpilot/sunnypilot/modeld_v2/warmup.py diff --git a/.github/workflows/build-default-models.yaml b/.github/workflows/build-default-models.yaml index c51bf5cd74..f75c826851 100644 --- a/.github/workflows/build-default-models.yaml +++ b/.github/workflows/build-default-models.yaml @@ -117,20 +117,6 @@ jobs: ${{ github.workspace }}/${{ env.SMALL_PKL }} \ --device-input "*" --out-of-band --benchmark-runs 1 - - name: Chunk small pkl - run: | - source ${UV_PROJECT_ENVIRONMENT}/bin/activate - export PYTHONPATH=${{ github.workspace }} - python3 -c " - from openpilot.common.file_chunker import chunk_file, get_chunk_targets - import os - pkl = '${{ github.workspace }}/${{ env.SMALL_PKL }}' - size = os.path.getsize(pkl) - targets = get_chunk_targets(pkl, size) - chunk_file(pkl, targets) - print(f'Chunked into {len(targets)} files') - " - - name: Prepare output env: MODEL_NAME: ${{ needs.resolve.outputs.safe_model_name }} @@ -142,8 +128,7 @@ jobs: PKL_BASE="driving_tinygrad.pkl" mkdir -p "$OUTPUT_DIR" - cp "$MODELS_DIR/${PKL_BASE}".chunk* "$OUTPUT_DIR/" - cp "$MODELS_DIR/${PKL_BASE}.chunkmanifest" "$OUTPUT_DIR/" + cp "$MODELS_DIR/${PKL_BASE}" "$OUTPUT_DIR/" ONNX_SHA256=$(git cat-file blob "$(git ls-files -s "${{ github.workspace }}/${{ env.SMALL_ONNX }}" | awk '{print $2}')" | grep '^oid sha256:' | cut -d: -f2) @@ -221,20 +206,6 @@ jobs: raise RuntimeError('Chestnut PCIe link not ready after 10 attempts') " - - name: Chunk big pkl - run: | - source ${UV_PROJECT_ENVIRONMENT}/bin/activate - export PYTHONPATH=${{ github.workspace }} - python3 -c " - from openpilot.common.file_chunker import chunk_file, get_chunk_targets - import os - pkl = '${{ github.workspace }}/${{ env.BIG_PKL }}' - size = os.path.getsize(pkl) - targets = get_chunk_targets(pkl, size) - chunk_file(pkl, targets) - print(f'Chunked into {len(targets)} files') - " - - name: Compile big warp run: | source ${UV_PROJECT_ENVIRONMENT}/bin/activate @@ -274,8 +245,7 @@ jobs: PKL_BASE="big_driving_tinygrad.pkl" mkdir -p "$OUTPUT_DIR" - cp "$MODELS_DIR/${PKL_BASE}".chunk* "$OUTPUT_DIR/" - cp "$MODELS_DIR/${PKL_BASE}.chunkmanifest" "$OUTPUT_DIR/" + cp "$MODELS_DIR/${PKL_BASE}" "$OUTPUT_DIR/" PKL_LFS_SHA256=$(git cat-file blob "$(git ls-files -s "${{ github.workspace }}/${{ env.BIG_PKL }}" | awk '{print $2}')" | grep '^oid sha256:' | cut -d: -f2) @@ -390,24 +360,14 @@ jobs: from datetime import datetime, UTC dm_dir = Path('dm_output') - manifest = list(dm_dir.glob('*.chunkmanifest')) - assert manifest, 'No chunkmanifest found' - pkl_name = manifest[0].name.removesuffix('.chunkmanifest') - num_chunks = int(manifest[0].read_text().strip()) - - chunks = [] - for i in range(num_chunks): - chunk = dm_dir / f'{pkl_name}.chunk{i+1:02d}of{num_chunks:02d}' - chunks.append({ - 'file_name': chunk.name, - 'sha256': hashlib.sha256(chunk.read_bytes()).hexdigest() - }) + matches = sorted(dm_dir.glob('dmonitoring_*_tinygrad.pkl')) + assert len(matches) == 1, f'expected one DM pkl in {dm_dir}, found {matches}' + pkl_name = matches[0].name digest = hashlib.sha256() - for c in chunks: - with open(dm_dir / c['file_name'], 'rb') as f: - while block := f.read(1024*1024): - digest.update(block) + with open(dm_dir / pkl_name, 'rb') as f: + while block := f.read(1024*1024): + digest.update(block) metadata = { 'bundles': [{ @@ -421,8 +381,7 @@ jobs: 'type': 'chunked', 'artifact': { 'file_name': pkl_name, - 'download_uri': {'url': '', 'sha256': digest.hexdigest()}, - 'chunks': chunks + 'download_uri': {'url': '', 'sha256': digest.hexdigest()} } }] }] @@ -485,20 +444,6 @@ jobs: ${{ github.workspace }}/${{ env.DM_ONNX }} \ ${{ github.workspace }}/${{ env.DM_PKL }} - - name: Chunk DM pkl - run: | - source ${UV_PROJECT_ENVIRONMENT}/bin/activate - export PYTHONPATH=${{ github.workspace }} - python3 -c " - from openpilot.common.file_chunker import chunk_file, get_chunk_targets - import os - pkl = '${{ github.workspace }}/${{ env.DM_PKL }}' - size = os.path.getsize(pkl) - targets = get_chunk_targets(pkl, size) - chunk_file(pkl, targets) - print(f'Chunked {pkl} into {len(targets)} chunks') - " - - name: Compile DM warp run: | source ${UV_PROJECT_ENVIRONMENT}/bin/activate @@ -531,8 +476,7 @@ jobs: - name: Prepare DM output run: | mkdir -p dm_output - cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunk* dm_output/ - cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunkmanifest dm_output/ + cp ${{ github.workspace }}/${{ env.DM_PKL }} dm_output/ cp ${{ github.workspace }}/openpilot/selfdrive/modeld/models/dm_warp_* dm_output/ - name: Upload DM artifact diff --git a/.github/workflows/build-single-tinygrad-model.yaml b/.github/workflows/build-single-tinygrad-model.yaml index 4badd9a224..077077a714 100644 --- a/.github/workflows/build-single-tinygrad-model.yaml +++ b/.github/workflows/build-single-tinygrad-model.yaml @@ -137,6 +137,7 @@ jobs: needs: build_model runs-on: ubuntu-latest permissions: + actions: read id-token: write contents: write steps: @@ -179,18 +180,21 @@ jobs: echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT - name: Download and extract model artifact - uses: actions/download-artifact@v4 - with: - name: ${{ steps.read-artifact-name.outputs.artifact_name }} - path: output + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }} + run: | + id=$(gh api -X GET "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" -f name="$ARTIFACT_NAME" --jq '.artifacts[0].id') + url=$(curl -sf -o /dev/null -w '%{redirect_url}' -H "Authorization: Bearer $GH_TOKEN" "https://api.github.com/repos/$GITHUB_REPOSITORY/actions/artifacts/$id/zip") + sudo apt-get install -y -qq aria2 > /dev/null + aria2c -q -x16 -s16 -k8M --max-tries=5 --file-allocation=none -o model.zip "$url" && unzip -q model.zip -d output && rm model.zip - name: Create models folder env: ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }} run: | mkdir -p "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}" - cp -r output/* "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/" - rm -f "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/artifact_name.txt" + cp output/metadata.json "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/" - name: Upload to Hugging Face env: diff --git a/.github/workflows/download-hf-model-chunks/action.yml b/.github/workflows/download-hf-model/action.yml similarity index 74% rename from .github/workflows/download-hf-model-chunks/action.yml rename to .github/workflows/download-hf-model/action.yml index 2eb87cfbb7..863d81c89f 100644 --- a/.github/workflows/download-hf-model-chunks/action.yml +++ b/.github/workflows/download-hf-model/action.yml @@ -1,5 +1,5 @@ -name: Download HF model chunks -description: Resolve and download model chunks from HuggingFace in parallel +name: Download HF model +description: Resolve and download models from HuggingFace in parallel inputs: hf_repo: @@ -19,7 +19,7 @@ inputs: runs: using: composite steps: - - name: Download model chunks + - name: Download models shell: bash env: HF_REPO: ${{ inputs.hf_repo }} @@ -33,7 +33,7 @@ runs: resolve_chunks() { local HF_PATH="$1" ONNX_HASH="$2" CANONICAL="$3" DEST_DIR="$4" local JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_PATH}/default_models.json" - local DEFAULTS BUNDLE ARTIFACT BASE_URL NUM_CHUNKS + local DEFAULTS BUNDLE ARTIFACT FILE_URL BASE_URL DEFAULTS=$(curl -fsSL "$JSON_URL") if [ -n "$TINYGRAD_REF" ]; then BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" --arg tg "$TINYGRAD_REF" ' @@ -51,20 +51,12 @@ runs: fi ARTIFACT=$(echo "$BUNDLE" | jq -r '.models[0].artifact') - BASE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url' | sed 's|/[^/]*$||') - NUM_CHUNKS=$(echo "$ARTIFACT" | jq -r '.chunks | length') + FILE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url') + BASE_URL=$(echo "$FILE_URL" | sed 's|/[^/]*$||') mkdir -p "$DEST_DIR" - while IFS= read -r CHUNK_NAME; do - CHUNK_IDX=$(echo "$CHUNK_NAME" | grep -oP 'chunk\K[0-9]+of[0-9]+' || true) - if [ -z "$CHUNK_IDX" ]; then - echo "::error::Failed to parse chunk index from: $CHUNK_NAME" - return 1 - fi - ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${CHUNK_NAME}', safe=':/'))") - printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${CANONICAL}.chunk${CHUNK_IDX}" >> "$DOWNLOAD_LIST" - done < <(echo "$ARTIFACT" | jq -r '.chunks[].file_name') - echo "$NUM_CHUNKS" > "${DEST_DIR}/${CANONICAL}.chunkmanifest" + ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${FILE_URL}', safe=':/'))") + printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${CANONICAL}" >> "$DOWNLOAD_LIST" if [ "$CANONICAL" = "dmonitoring_model_tinygrad.pkl" ]; then for warp in dm_warp_1928x1208_tinygrad.pkl dm_warp_1344x760_tinygrad.pkl; do @@ -87,7 +79,7 @@ runs: done TOTAL=$(wc -l < "$DOWNLOAD_LIST") - echo "Downloading $TOTAL chunks with 8 parallel connections..." + echo "Downloading $TOTAL files with 8 parallel connections..." xargs -P8 -d'\n' -I{} bash -c ' URL="${1%% *}" DEST="${1#* }" diff --git a/.github/workflows/sunnypilot-build-model.yaml b/.github/workflows/sunnypilot-build-model.yaml index 199c63f7ae..cf28033a58 100644 --- a/.github/workflows/sunnypilot-build-model.yaml +++ b/.github/workflows/sunnypilot-build-model.yaml @@ -149,7 +149,17 @@ jobs: git lfs pull -X "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/dmonitoring_*.onnx,**/selfdrive/modeld/models/big_*.pkl,**/selfdrive/modeld/models/dmonitoring_*.pkl" rm -f selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/dmonitoring_*.onnx selfdrive/modeld/models/big_*.pkl selfdrive/modeld/models/dmonitoring_*.pkl else - git lfs pull -I "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/big_*.pkl" -X "" + sudo apt-get install -y -qq aria2 > /dev/null # Parallel ranges because gh is flaky + LFS_URL=$(git config -f "$(git rev-parse --show-toplevel)/.lfsconfig" lfs.url) + for f in selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/big_*.pkl; do + grep -qs "^version https://git-lfs" "$f" || continue + OID=$(awk -F'sha256:' '/^oid/{print $2}' "$f"); SIZE=$(awk '/^size/{print $2}' "$f") + HREF=$(curl -fsSL --retry 3 -X POST -H "Accept: application/vnd.git-lfs+json" -H "Content-Type: application/vnd.git-lfs+json" "$LFS_URL/objects/batch" \ + -d "{\"operation\":\"download\",\"transfer\":[\"basic\"],\"objects\":[{\"oid\":\"$OID\",\"size\":$SIZE}]}" | jq -r '.objects[0].actions.download.href') + aria2c -x 16 -s 16 -k 16M --max-tries=5 --retry-wait=5 --file-allocation=none --summary-interval=30 --allow-overwrite=true \ + -d "$(dirname "$f")" -o "$(basename "$f")" "$HREF" + echo "$OID $f" | sha256sum -c - + done find selfdrive/modeld/models -type f \( -name "*.onnx" -o -name "*.pkl" \) ! -name "big_*.onnx" ! -name "big_*.pkl" -delete fi fi @@ -162,6 +172,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: models-${{ env.REF }}${{ inputs.artifact_suffix }} + compression-level: 0 path: | ${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/*.onnx ${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/*.pkl @@ -224,11 +235,11 @@ jobs: MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')") CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')") - TG_FLAGS_QCOM="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1" + TG_FLAGS_QCOM="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 OPENPILOT_HACKS=1" if [ "${{ inputs.target_hardware }}" == "chestnut" ]; then echo "CHESTNUT build" export CHESTNUT=1 - TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32" + TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32" OUTPUT_PKL="${{ env.MODELS_DIR }}/big_driving_tinygrad.pkl" else echo "QCOM build" @@ -240,14 +251,15 @@ jobs: if [ -f "${{ env.MODELS_DIR }}/new_arch.txt" ]; then echo "Detected new arch" - DOWNLOADED_ONNX=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "*driving*.onnx" -print -quit) + if [ "${{ inputs.target_hardware }}" == "chestnut" ]; then + DOWNLOADED_ONNX=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "big_*driving*.onnx" -print -quit) + else + DOWNLOADED_ONNX=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "*driving*.onnx" ! -name "big_*" -print -quit) + fi if [ -n "$DOWNLOADED_ONNX" ]; then echo "Compiling ONNX: $DOWNLOADED_ONNX" env ${TG_FLAGS} taskset -c 7 python3 openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py \ - "$DOWNLOADED_ONNX" "$OUTPUT_PKL" --device-input "*" --out-of-band --benchmark-runs 1 - - echo "Chunking pkl" - python3 -c "from openpilot.common.file_chunker import chunk_file, get_chunk_targets; import os; p='$OUTPUT_PKL'; chunk_file(p, get_chunk_targets(p, os.path.getsize(p)))" + "$DOWNLOADED_ONNX" "$OUTPUT_PKL" --device-input "*" --benchmark-runs 1 else echo "Failed to find downloaded ONNX." exit 1 @@ -317,8 +329,7 @@ jobs: # Copy the model files rsync -avm \ --include='*.dlc' \ - --include='*.chunk*' \ - --include='*.chunkmanifest' \ + --include='/${{ inputs.target_hardware == 'chestnut' && 'big_' || '' }}driving_tinygrad.pkl' \ --exclude='*' \ --delete-excluded \ --chown=comma:comma \ @@ -340,6 +351,7 @@ jobs: with: name: model-${{ env.MODEL_NAME }}${{ inputs.artifact_suffix }}-${{ github.run_number }} path: ${{ env.OUTPUT_DIR }} + compression-level: 0 - name: Upload artifact name file uses: actions/upload-artifact@v4 diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index 7fdea327b9..78305f3fb4 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -503,8 +503,8 @@ jobs: mkdir -p ${{ env.OUTPUT_DIR }} tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }} - - name: Download model chunks from HF - uses: ./.github/workflows/download-hf-model-chunks + - name: Download models from HF + uses: ./.github/workflows/download-hf-model with: hf_repo: sunnypilot/sunnypilot_models_v1 dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models @@ -573,8 +573,8 @@ jobs: mkdir -p ${{ env.OUTPUT_DIR }} tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }} - - name: Download model chunks from HF - uses: ./.github/workflows/download-hf-model-chunks + - name: Download models from HF + uses: ./.github/workflows/download-hf-model with: hf_repo: sunnypilot/sunnypilot_models_v1 dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models diff --git a/.github/workflows/test_models.yml b/.github/workflows/test_models.yml index 769fca1051..2345385fca 100644 --- a/.github/workflows/test_models.yml +++ b/.github/workflows/test_models.yml @@ -47,38 +47,22 @@ jobs: with: submodules: true - - name: Download Model Chunks in Parallel + - name: Download model env: HF_TOKEN: ${{ secrets.HF_TOKEN }} + MODEL_URL: ${{ matrix.artifact.download_uri.url }} + MODEL_FILE: ${{ matrix.artifact.file_name }} + shell: bash run: | - mkdir -p /tmp/model_chunks - echo '${{ toJson(matrix.artifact.chunks) }}' > chunks.json - - BASE_URL="${{ matrix.artifact.download_uri.url }}" - export BASE_DIR=$(dirname "$BASE_URL") - - python3 -c ' - import json, os - with open("chunks.json") as f: - chunks = json.load(f) - manifest_path = f"/tmp/model_chunks/${{ matrix.artifact.file_name }}.chunkmanifest" - with open(manifest_path, "w") as f: - f.write(str(len(chunks))) - base_dir = os.environ["BASE_DIR"] - hf_token = os.environ.get("HF_TOKEN", "") - with open("/tmp/curl_config.txt", "w") as f: - for c in chunks: - fn = c["file_name"] - f.write(f"url = \"{base_dir}/{fn}\"\noutput = \"/tmp/model_chunks/{fn}\"\n") - if hf_token: - f.write(f"header = \"Authorization: Bearer {hf_token}\"\n") - ' - curl -Z --parallel-immediate --parallel-max 12 --retry 3 --retry-all-errors -s -S -f -L -K /tmp/curl_config.txt + mkdir -p /tmp/models + AUTH=() + if [ -n "$HF_TOKEN" ]; then AUTH=(-H "Authorization: Bearer $HF_TOKEN"); fi + curl --retry 3 --retry-all-errors -s -S -f -L "${AUTH[@]}" -o "/tmp/models/$MODEL_FILE" "$MODEL_URL" - name: Run Model Compatibility Test env: MODEL_BASE_NAME: ${{ matrix.artifact.file_name }} - MODEL_CHUNK_DIR: "/tmp/model_chunks" + MODEL_DIR: "/tmp/models" run: | export PYTHONPATH="$GITHUB_WORKSPACE/tinygrad_repo:$GITHUB_WORKSPACE:$PYTHONPATH" python3 -m pip uninstall -y tinygrad || uv pip uninstall -y tinygrad --system || true diff --git a/openpilot/sunnypilot/modeld_v2/compile_modeld.py b/openpilot/sunnypilot/modeld_v2/compile_modeld.py index 2b7256aeb9..ab338f3ad5 100755 --- a/openpilot/sunnypilot/modeld_v2/compile_modeld.py +++ b/openpilot/sunnypilot/modeld_v2/compile_modeld.py @@ -7,14 +7,12 @@ See the LICENSE.md file in the root directory for more details. """ import argparse -import atexit import codecs import gc import math import multiprocessing as mp import os import pickle -import shutil import tempfile import time from functools import partial @@ -22,8 +20,8 @@ from typing import Any import numpy as np -from openpilot.common.file_chunker import chunk_file, get_chunk_targets, open_file_chunked -from openpilot.sunnypilot.modeld_v2.helpers import dump_oob, load_oob +from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, read_parts, write_bundle +from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit import openpilot.sunnypilot.modeld_v2.stock_dependencies as stock from openpilot.system.camerad.cameras.nv12_info import get_nv12_info @@ -55,16 +53,6 @@ def _patch_tinygrad_fetch_fw(): pass _patch_tinygrad_fetch_fw() -def _patch_ops_serialization(): - try: - from tinygrad.uop.ops import Ops - def __reduce_ex__(self, proto): - return (getattr, (self.__class__, self.name)) - Ops.__reduce_ex__ = __reduce_ex__ - except ImportError: - pass -_patch_ops_serialization() - MODEL_TYPES = ('vision_policy', 'supercombo', 'vision_multi_policy') WARP_INPUTS = ['tfm', 'big_tfm'] POLICY_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs'] @@ -72,6 +60,7 @@ POLICY_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs' class MetadataOnnxPBParser(OnnxPBParser): def _parse_ModelProto(self) -> dict: obj: dict[str, Any] = {"graph": {"input": [], "output": []}, "metadata_props": []} + self.opset_imports = {} for fid, wire_type in self._parse_message(self.reader.len): match fid: case 7: @@ -150,6 +139,10 @@ def get_policy_npy_shapes(input_shapes: dict, is_supercombo: bool = False) -> tu return shapes, sizes +def host_view(tensor: Tensor) -> np.ndarray: # QCOM -> CPU round trip of tfm takes 5ms longer than host memory view + return np.frombuffer(tensor._buffer()._host_mv(), dtype=np.float32).reshape(tensor.shape) + + def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = Device.DEFAULT, is_supercombo: bool = False) -> tuple[dict, dict]: road_key, _ = _detect_vision_keys(input_shapes) @@ -185,7 +178,7 @@ def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = D 'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), 'desire_q': Tensor(np.zeros((frame_skip * desire_shape[1], desire_shape[0], desire_shape[2]), dtype=np.float32), device=device).contiguous().realize(), - 'packed_npy_inputs': Tensor(packed_npy_inputs, device=device if os.getenv('CHESTNUT') else 'NPY').realize(), + 'packed_npy_inputs': Tensor(packed_npy_inputs, device=device).realize(), } if features_buffer: @@ -197,9 +190,11 @@ def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = D for key in ('tfm', 'big_tfm'): if key not in npy_arrays: npy_arrays[key] = np.eye(3, dtype=np.float32) - queues[key] = Tensor(npy_arrays[key], device='NPY').realize() + queues[key] = Tensor(npy_arrays[key], device=device).realize() - return queues, npy_arrays + packed = host_view(queues['packed_npy_inputs']) + npy_arrays |= {k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed, split_indices) if sizes else [], strict=True)} + return queues, npy_arrays | {k: host_view(queues[k]) for k in ('tfm', 'big_tfm')} def make_split_input_queues(vision_input_shapes: dict, policy_input_shapes: dict, @@ -221,8 +216,8 @@ def make_warp_queues(device=Device.DEFAULT): 'tfm': np.zeros((3, 3), dtype=np.float32), 'big_tfm': np.zeros((3, 3), dtype=np.float32), } - queues = {k: Tensor(v, device='NPY').realize() for k, v in npy.items()} - return queues, npy + queues = {k: Tensor(v, device=device).realize() for k, v in npy.items()} + return queues, {k: host_view(v) for k, v in queues.items()} def make_run_policy(vision_runner, policy_runners: list, features_slice: slice, frame_skip: int, input_shapes: dict): @@ -282,8 +277,19 @@ def make_run_policy(vision_runner, policy_runners: list, features_slice: slice, return run_policy +class _CheckFrameRecorder: + def __init__(self, jit): + self.jit, self.frame = jit, None + + def __call__(self, *args, **kwargs): + if self.frame is not None or args or self.jit.cnt < 2: + return self.jit(*args, **kwargs) + self.frame, result = capture_frame(self.jit, kwargs, None) + return result + + def compile_jit(jit_or_fn, input_keys_or_make_inputs, make_queues=None, make_random_inputs=None, - benchmark_runs: int = 1, clear_refs=None, return_loaded: bool = True): + benchmark_runs: int = 1, clear_refs=None): if callable(input_keys_or_make_inputs) and make_queues is None: fn = jit_or_fn make_inputs = input_keys_or_make_inputs @@ -334,22 +340,23 @@ def compile_jit(jit_or_fn, input_keys_or_make_inputs, make_queues=None, make_ran print('capture + replay') gc.collect() - test_val = run_eval(jit, 42, 3) - print(f'pickle round trip ({benchmark_runs} runs per seed)') - with tempfile.TemporaryFile(dir=".") as f: - dump_oob(jit, f) - if clear_refs: - clear_refs() - gc.collect() - f.seek(0) - loaded_jit = load_oob(f) + recorder = _CheckFrameRecorder(jit) + with record_kernel_linear() as recorded: + test_val = run_eval(recorder, 42, 3) + assert len(recorded) == 1, f"expected one captured kernel list, got {len(recorded)}" + part = serialize_jit(jit, recorded[0], recorder.frame) + print(f'plain artifact round trip ({benchmark_runs} runs per seed)') + if clear_refs: + clear_refs() + gc.collect() + loaded_jit = PlainJit(part[0], memoryview(part[1])) for seed in (42, 43): reference = test_val if seed == 42 else run_eval(jit_or_fn, seed, 1) actual = run_eval(loaded_jit, seed, benchmark_runs) for ref, val in zip(reference, actual, strict=True): np.testing.assert_array_equal(ref, val) - return loaded_jit if return_loaded else jit + return part def _parse_size(size_str: str) -> tuple[int, int]: @@ -357,16 +364,6 @@ def _parse_size(size_str: str) -> tuple[int, int]: return int(width), int(height) -def read_file_chunked_to_disk(path): - if not path: - return None - tmp_path = f'{path}.unchunked' - with open(tmp_path, 'wb') as f, open_file_chunked(path) as src: - shutil.copyfileobj(src, f) - atexit.register(lambda: os.path.exists(tmp_path) and os.remove(tmp_path)) - return tmp_path - - def _load_policy_runners(args: argparse.Namespace) -> tuple[list, list]: runners, keys = [], [] for name, onnx_arg in [('policy', args.policy_onnx), ('off_policy', args.off_policy_onnx), ('on_policy', args.on_policy_onnx)]: @@ -387,10 +384,7 @@ def _compile_warp_resolution_worker(cam_w, cam_h, model_w, model_h, benchmark_ru warp = None compiled_jit = compile_jit(warp, WARP_INPUTS, make_warp_queues, make_random_inputs=make_random_warp_inputs, benchmark_runs=benchmark_runs, clear_refs=cleanup_warp) - - result_data = (compiled_jit, Device.DEFAULT) - with open(result_path, "wb") as f: - dump_oob(result_data, f) + write_bundle(result_path, {(cam_w, cam_h): compiled_jit}, {'device': Device.DEFAULT}) if __name__ == "__main__": @@ -421,12 +415,6 @@ if __name__ == "__main__": model_w, model_h = args.model_size output_data = {} - args.vision_onnx = read_file_chunked_to_disk(args.vision_onnx) - args.policy_onnx = read_file_chunked_to_disk(args.policy_onnx) - args.off_policy_onnx = read_file_chunked_to_disk(args.off_policy_onnx) - args.on_policy_onnx = read_file_chunked_to_disk(args.on_policy_onnx) - args.supercombo_onnx = read_file_chunked_to_disk(args.supercombo_onnx) - is_unified_supercombo = False if args.model_type == 'supercombo': assert args.supercombo_onnx @@ -449,8 +437,10 @@ if __name__ == "__main__": frame_copy_size=frame_copy_size) warp = stock.make_warp(nv12, model_w, model_h) run_model_jit = TinyJit(stock.make_run_model(warp, run_policy, model_metadata, frame_copy_size), prune=True) - compiled_jit = compile_jit(run_model_jit, stock.MODELD_INPUTS, make_model_queues, benchmark_runs=args.benchmark_runs, return_loaded=False) - output_data[(cam_w, cam_h)] = compiled_jit + write_bundle(part_path := f"{args.output}.{cam_w}x{cam_h}", {(cam_w, cam_h): compile_jit(run_model_jit, stock.MODELD_INPUTS, make_model_queues, + benchmark_runs=args.benchmark_runs)}) + output_data[(cam_w, cam_h)] = read_parts(part_path)[0][(cam_w, cam_h)] + os.remove(part_path) gc.collect() del model_runner, run_policy gc.collect() @@ -518,18 +508,12 @@ if __name__ == "__main__": tmp_files.append((cam_w, cam_h, tmp_res.name)) for cam_w, cam_h, tmp_name in tmp_files: - with open(tmp_name, "rb") as f: - compiled_jit, dev_name = load_oob(f) - output_data[(cam_w, cam_h)] = compiled_jit - output_data['input_devices']['warp'] = dev_name + warp_parts, warp_extras = read_parts(tmp_name) + output_data[(cam_w, cam_h)] = warp_parts[(cam_w, cam_h)] + output_data['input_devices']['warp'] = warp_extras['device'] os.remove(tmp_name) output_data['metadata']['warp_dev'] = Device.DEFAULT - with open(args.output, "wb") as file: - dump_oob(output_data, file) - - pkl_size = os.path.getsize(args.output) - print(f"Saved combined JIT to {args.output} ({pkl_size / 1e6:.2f} MB)") - chunk_targets = get_chunk_targets(args.output, pkl_size) - chunk_file(args.output, chunk_targets) - print(f"Chunked into {len(chunk_targets) - 1} file(s)") + parts = {key: output_data.pop(key) for key in [key for key, value in output_data.items() if isinstance(value, tuple)]} + write_bundle(args.output, parts, output_data) + print(f"Saved model to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB, {len(parts)} JITs)") diff --git a/openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py b/openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py index a4bfcce313..44ff5fed67 100755 --- a/openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py +++ b/openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py @@ -1,11 +1,59 @@ #!/usr/bin/env python3 -import runpy +import argparse import os +import sys -from openpilot.sunnypilot.modeld_v2.compile_modeld import _patch_ops_serialization +import numpy as np + +from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, write_bundle +from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit + + +def initialize_check_inputs(arrays: dict): + rng = np.random.default_rng(7) + for value in arrays.values(): + value[...] = rng.standard_normal(value.shape) if value.dtype.kind == "f" else rng.integers(0, 2 if value.dtype == np.bool_ else 16, value.shape) if __name__ == "__main__": - _patch_ops_serialization() - upstream_script = os.path.join(os.environ.get("TINYGRAD_PATH", "tinygrad_repo"), "examples/openpilot/compile_onnx.py") - runpy.run_path(upstream_script, run_name="__main__") + sys.path.insert(0, os.environ.get("TINYGRAD_PATH", "tinygrad_repo")) + from examples.openpilot.compile_onnx import onnx_metadata + from examples.openpilot.helpers import allocate_inputs, benchmark + from tinygrad import Device, Tensor, TinyJit + from tinygrad.dtype import _to_np_dtype + from tinygrad.nn.onnx import OnnxRunner + + parser = argparse.ArgumentParser(description="Compile a native-architecture ONNX model into a plain tinygrad artifact") + parser.add_argument('onnx') + parser.add_argument('output') + parser.add_argument('--device-input', action='append', default=[]) + parser.add_argument('--benchmark-runs', type=int, default=20) + args = parser.parse_args() + + runner, (properties, output_shapes) = OnnxRunner(args.onnx), onnx_metadata(args.onnx) + device_inputs = runner.graph_inputs.keys() if '*' in args.device_input else args.device_input + metadata = {'metadata': properties} | {f'{kind}_shapes': {name: tuple(d if isinstance(d, int) else 0 for d in shape) for name, shape in shapes.items()} + for kind, shapes in [('input', {name: spec.shape for name, spec in runner.graph_inputs.items()}), + ('output', output_shapes)]} + input_specs = {name: (tuple(s if isinstance(s, int) else 1 for s in spec.shape), np.dtype(_to_np_dtype(spec.dtype)).str, + Device.DEFAULT if name in device_inputs else 'NPY') for name, spec in runner.graph_inputs.items()} + + def model(inputs): + return {name: value.contiguous() for name, value in runner({name: value.to(Device.DEFAULT) for name, value in inputs.items()}).items()} + output_specs = {name: (value.shape, np.dtype(_to_np_dtype(value.dtype)).name, Device.DEFAULT) for name, value in model(allocate_inputs(input_specs)).items()} + + @TinyJit(prune=True) + def jit(output_buffers, **inputs): + Tensor.realize(*(output_buffers[name].assign(value) for name, value in model(inputs).items())) + + check_inputs = allocate_inputs(input_specs, initialize_check_inputs) | {'output_buffers': allocate_inputs(output_specs)} + with record_kernel_linear() as recorded: + for i in range(2 + args.benchmark_runs): + benchmark(jit, cb=lambda t, i=i: print(f" [{i + 1}/{2 + args.benchmark_runs}] {t * 1e3:.2f} ms"), **check_inputs) + assert len(recorded) == 1, f"expected one captured kernel list, got {len(recorded)}" + + frame, _ = capture_frame(jit, check_inputs, 'output_buffers') + part = serialize_jit(jit, recorded[0], frame) + PlainJit(part[0], memoryview(part[1])) + write_bundle(args.output, {'run': part}, {'metadata': metadata, 'input_specs': input_specs, 'output_specs': output_specs}) + print(f"Saved model to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB, {len(part[0]['calls'])} kernels)") diff --git a/openpilot/sunnypilot/modeld_v2/helpers.py b/openpilot/sunnypilot/modeld_v2/helpers.py index 03012be3a9..8d0fc0e7fd 100644 --- a/openpilot/sunnypilot/modeld_v2/helpers.py +++ b/openpilot/sunnypilot/modeld_v2/helpers.py @@ -5,14 +5,23 @@ This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ +import builtins +import contextlib +import dataclasses +import functools +import hashlib import io -import struct +import json +import math +import mmap +import os import pickle -import inspect -import importlib -import tempfile -import shutil -import enum +import struct + +MAGIC = b"SPTGPLN1" +ALIGNMENT = 256 +METADATA_TYPES = ("slice", "tuple", "list", "dict", "set", "frozenset", "complex") +RUNTIME_ENVIRONMENT = ("GMMU", "AMD_AQL", "AMD_DISABLE_SDMA", "GRAPH_ONE_KERNEL") def _patch_system_flock_acquire(): @@ -32,86 +41,246 @@ def _patch_system_flock_acquire(): pass _patch_system_flock_acquire() -def _pad_args(func, args, kwargs): - try: - sig = inspect.signature(func) - except Exception: - return args, kwargs - params = list(sig.parameters.values()) - if inspect.isfunction(func) and params and params[0].name in ('cls', 'self'): - params = params[1:] - new_args = list(args) - has_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params) - if len(new_args) > len(params) and not has_varargs: - new_args = new_args[:len(params)] - - for i in range(len(new_args), len(params)): - param = params[i] - if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): - continue - val = param.default if param.default is not inspect.Parameter.empty else None - new_args.append(val) - return new_args, kwargs +class ArtifactMismatch(RuntimeError): + pass -def _dynamic_factory(real_class): - if isinstance(real_class, type) and issubclass(real_class, enum.Enum): - return real_class +class Blob: + def __init__(self, sink=None): + self.chunks: list = [] + self.locations: dict[bytes, list[int]] = {} + self.size, self.write = 0, sink.write if sink else self.chunks.append - def factory(*args, **kwargs): - try: - return real_class(*args, **kwargs) - except TypeError: - new_args, new_kwargs = _pad_args(real_class, args, kwargs) - return real_class(*new_args, **new_kwargs) - - class DynamicMeta(type(real_class)): - def __call__(cls, *args, **kwargs): - return factory(*args, **kwargs) - - class DynamicProxy(real_class, metaclass=DynamicMeta): - __slots__ = () - - def __new__(cls, *args, **kwargs): - return factory(*args, **kwargs) - - DynamicProxy.__name__ = real_class.__name__ - DynamicProxy.__module__ = real_class.__module__ - return DynamicProxy + def add(self, data) -> list[int]: + if (key := hashlib.blake2b(data, digest_size=16).digest()) not in self.locations: + start = self.size + -self.size % ALIGNMENT + self.write(bytes(start - self.size)), self.write(data) + self.locations[key], self.size = [start, start + memoryview(data).nbytes], start + memoryview(data).nbytes + return self.locations[key] -class DynamicTinygradUnpickler(pickle.Unpickler): +class _MetadataUnpickler(pickle.Unpickler): def find_class(self, module, name): - real_class = getattr(importlib.import_module(module), name) - if module.startswith("tinygrad"): - return _dynamic_factory(real_class) - return real_class + if module == "builtins" and name in METADATA_TYPES: + return getattr(builtins, name) + raise pickle.UnpicklingError(f"{module}.{name} is not allowed in model metadata") -def load_oob(f): - opcodes = f.read(struct.unpack(' list: + if isinstance(value, (tuple, list)): + return [tensor for item in value for tensor in flatten(item)] + return [] if value is None else [value] -def dump_oob(obj, f): - with tempfile.TemporaryFile(dir=".") as tmp: - def buffer_cb(buffer): - data = buffer.raw() - tmp.write(struct.pack(' tuple[dict, dict]: + with open(path, "rb") as file: + data = memoryview(mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_READ)) + if bytes(data[:len(MAGIC)]) != MAGIC: + raise ValueError(f"{path} is not a plain tinygrad model, recompile it with compile_modeld.py") + start = len(MAGIC) + 8 + struct.unpack_from(" dict: + parts, extras = read_parts(path) + if cache and (cached := _read_cache(path, parts, device_map)) is not None: + return extras | cached + shared: dict = {} + jits = {key: PlainJit(header, blob, device_map, verify, shared) for key, (header, blob) in parts.items()} + if cache: + _write_cache(path, jits, device_map) + return extras | jits + + +@functools.cache +def tinygrad_identity() -> str: + import tinygrad + root, digest = os.path.dirname(tinygrad.__file__), hashlib.sha256() + for directory, _, files in sorted(os.walk(root)): + for name in sorted(file for file in files if file.endswith(".py")): + stat = os.stat(path := os.path.join(directory, name)) + digest.update(f"{os.path.relpath(path, root)}:{stat.st_size}:{stat.st_mtime_ns};".encode()) + return digest.hexdigest() + + +def _cache_key(path: str, device_map: dict | None) -> bytes: + stat = os.stat(path) + return json.dumps({"format": MAGIC.decode(), "tinygrad": tinygrad_identity(), "size": stat.st_size, "mtime_ns": stat.st_mtime_ns, + "device_map": sorted((device_map or {}).items()), "env": [os.environ.get(name, "") for name in RUNTIME_ENVIRONMENT]}).encode() + + +def _cache_header_matches(file, path: str, device_map: dict | None) -> bool: + return file.read(struct.unpack(" bool: # entry point for warmup proc + try: + with open(f"{path}.cache", "rb") as file: + return _cache_header_matches(file, path, device_map) + except (OSError, struct.error): + return False + + +def _read_cache(path: str, parts: dict, device_map: dict | None) -> dict | None: + try: + with open(f"{path}.cache", "rb") as file: + if not _cache_header_matches(file, path, device_map): + return None + shared: dict = {} + buffers = {repr(key): _allocate_buffers(header, blob, device_map, shared) for key, (header, blob) in parts.items()} + return type("_Unpickler", (pickle.Unpickler,), {"persistent_load": lambda self, pid: buffers[pid[0]][pid[1]]})(file).load() + except Exception: + return None + + +def _write_cache(path: str, jits: dict, device_map: dict | None) -> None: + from tinygrad.device import Buffer + owners = {id(buffer): (repr(key), index) for key, jit in jits.items() for index, buffer in enumerate(jit.buffers)} + + class _Pickler(pickle.Pickler): + def persistent_id(self, obj): + return owners.get(id(obj)) if isinstance(obj, Buffer) else None + + key, tmp_path = _cache_key(path, device_map), f"{path}.cache.{os.getpid()}.tmp" + with contextlib.suppress(Exception): + with open(tmp_path, "wb") as file: + file.write(struct.pack(" str: + from tinygrad import Device + device_map = device_map or {} + return Device.canonicalize(device_map.get(recorded, device_map.get(recorded.split(":", 1)[0], recorded))) + + +def _allocate_buffers(header: dict, blob: memoryview, device_map: dict | None, shared: dict) -> list: + import numpy as np + from tinygrad.device import Buffer + written = {call["args"][i]["buffer"] for call in header["calls"] for i in (call["outs"] if call["kind"] == "program" else (0,)) + if "buffer" in call["args"][i]} + buffers = [] + for index, entry in enumerate(header["buffers"]): + device, data = _device(entry["device"], device_map), blob[slice(*entry["init"])] if "init" in entry else None + # read-only data the parts share in the file (weights common to every camera resolution) is uploaded once to the nut + key = (device, entry["dtype"], np.frombuffer(data, np.uint8).ctypes.data, len(data)) if data and index not in written else object() + if key not in shared: + shared[key] = Buffer(device, entry["nbytes"], initial_value=pickle.PickleBuffer(data) if data else None).ensure_allocated() + buffers.append(shared[key]) + return buffers + + +def _program_uop(call: dict, lib: bytes): + from tinygrad.helpers import Target + from tinygrad.uop.ops import KernelInfo, Ops, ProgramInfo, UOp + parameters = [] + for name, compact_slot, dtype, shape in call["signature"]: + parameter = UOp.param(call["globals"][compact_slot], _dtype(dtype), math.prod(shape)) + image = (shape[0], shape[1]) if len(shape) == 3 and shape[2] == 4 else None + parameters.append(parameter.replace(arg=dataclasses.replace(parameter.arg, name=name, image=image))) + info = ProgramInfo(global_size=tuple(call["global_size"]), local_size=tuple(call["local_size"]), vars=(), globals=tuple(call["globals"]), + outs=tuple(call["outs"]), ins=tuple(call["ins"]), target=Target(**call["target"])) + return UOp(Ops.PROGRAM, src=(UOp(Ops.SINK, arg=KernelInfo(name=call["name"])), UOp(Ops.LINEAR, src=tuple(parameters)), + UOp(Ops.SOURCE, arg=""), UOp(Ops.BINARY, arg=lib)), arg=info) + + +class _Jit: + def __init__(self, captured, input_names: list, buffers: list): + self.captured, self.input_names, self.buffers = captured, input_names, buffers + + def __call__(self, *args, **kwargs): + import tinygrad.engine.jit as jit + input_uops, var_vals, names, _ = jit._prepare_jit_inputs(args, kwargs) + if names != self.input_names: + raise ValueError(f"expected inputs {self.input_names}, got {names}") + return self.captured(input_uops, var_vals) + + +class PlainJit(_Jit): + def __init__(self, header: dict, blob: memoryview, device_map: dict | None = None, verify: bool = True, shared: dict | None = None): + import numpy as np + import tinygrad.engine.jit as jit + from tinygrad import Tensor + from tinygrad.device import Buffer + from tinygrad.dtype import dtypes + from tinygrad.uop.ops import Ops, UOp + + inputs = {spec["slot"]: spec for spec in header["inputs"]} + buffers = _allocate_buffers(header, blob, device_map, {} if shared is None else shared) + bases = [UOp.from_buffer(buffer, _dtype(entry["dtype"])) for buffer, entry in zip(buffers, header["buffers"], strict=True)] + + def argument(described: dict): + if "input" in described: + spec = inputs[described["input"]] + return UOp.param(described["input"], _dtype(spec["dtype"]), spec["numel"], _device(spec["device"], device_map)) + base, dtype, offset, nbytes = bases[described["buffer"]], _dtype(described["dtype"]), described["offset"], described["nbytes"] + if offset == 0 and nbytes == header["buffers"][described["buffer"]]["nbytes"] and dtype == base.dtype: + return base + return (base if base.dtype.itemsize == 1 else base.bitcast(dtypes.uint8)).shrink(((offset, offset + nbytes),)).bitcast(dtype) + + calls = [] + for call in header["calls"]: + arguments = [argument(described) for described in call["args"]] + if call["kind"] == "program": + calls.append(_program_uop(call, bytes(blob[slice(*call["lib"])])).call(*arguments)) + else: + calls.append(arguments[0].store_call(arguments[1])) + # stand-ins for JIT inputs: copy staging to GPU is decided from the input buffers, the real ones bind per call + stand_ins = {slot: UOp.from_buffer(Buffer(_device(spec["device"], device_map), spec["numel"] * _dtype(spec["dtype"]).itemsize).ensure_allocated(), + _dtype(spec["dtype"])) for slot, spec in inputs.items()} + input_uops = tuple(stand_ins.get(slot, next(iter(stand_ins.values()), None)) for slot in range(max(stand_ins, default=-1) + 1)) + linear = jit.compile_linear(UOp(Ops.LINEAR, src=tuple(calls)), beam=0, input_uops=input_uops, cache=False) + + returned = [Tensor(argument(t)).reshape(tuple(t["shape"])) for t in header["returns"]["tensors"]] + ret = {"NoneType": None, "Tensor": returned[0] if returned else None, "tuple": tuple(returned), "list": returned}[header["returns"]["kind"]] + super().__init__(jit.CapturedJit(ret, linear, header["input_names"], []), header["input_names"], buffers) + + if not verify or not (check := header.get("check")): + return + def array(stored): + return np.frombuffer(blob[slice(*stored["data"])], dtype=np.dtype(stored["dtype"])).reshape(stored["shape"]).copy() + kwargs: dict = {} + for path, stored, device in check["inputs"]: + tensor = Tensor(array(stored), device=_device(device, device_map)).realize() + kwargs[path[0]] = tensor if len(path) == 1 else {**kwargs.get(path[0], {}), path[1]: tensor} + result = self(**kwargs) + outputs = kwargs[check["output_container"]].values() if check["output_container"] else flatten(result) + for index, (got, stored) in enumerate(zip([t.numpy() for t in outputs], check["outputs"], strict=True)): + expected = array(stored) + if got.shape != expected.shape or not np.allclose(got, expected, rtol=1e-3, atol=1e-3, equal_nan=True): + raise ArtifactMismatch(f"output {index} differs from the compile-time reference frame; recompile the model") diff --git a/openpilot/sunnypilot/modeld_v2/model_adapters.py b/openpilot/sunnypilot/modeld_v2/model_adapters.py index b240895180..e5f045d0ee 100644 --- a/openpilot/sunnypilot/modeld_v2/model_adapters.py +++ b/openpilot/sunnypilot/modeld_v2/model_adapters.py @@ -23,8 +23,8 @@ from tinygrad.uop.ops import UOp def input_view(buffer: Buffer, shape: tuple[int, ...], dtype: DType, offset: int) -> Tensor: - view = buffer.view(math.prod(shape), dtype, offset).ensure_allocated() - return Tensor(UOp.from_buffer(view)).reshape(shape) + view = buffer.view(math.prod(shape) * dtype.itemsize, offset).ensure_allocated() + return Tensor(UOp.from_buffer(view, dtype)).reshape(shape) class BaseModelAdapter: @@ -168,8 +168,7 @@ class NativeTinygradAdapter(BaseModelAdapter): stride, y_height, uv_height, _ = get_nv12_info(self.cam_w, self.cam_h) self.frame_copy_size = stride * (y_height + uv_height) - self.input_shapes_orig = self.jits['metadata']['input_shapes'] - self._vision_input_names = [k for k in self.input_shapes_orig if 'img' in k] + self._vision_input_names = ['img', 'big_img'] self.vision_output_slices = pickle.loads(codecs.decode(self.jits['metadata']['metadata']['output_slices'].encode(), 'base64')) self.run_warp = self._load_warp() diff --git a/openpilot/sunnypilot/modeld_v2/modeld.py b/openpilot/sunnypilot/modeld_v2/modeld.py index 2fd1baf999..b669868d6f 100755 --- a/openpilot/sunnypilot/modeld_v2/modeld.py +++ b/openpilot/sunnypilot/modeld_v2/modeld.py @@ -24,7 +24,6 @@ from openpilot.cereal.messaging import PubMaster, SubMaster from openpilot.cereal.visionipc import VisionStreamType from msgq.visionipc import VisionIpcClient, VisionBuf from opendbc.car.car_helpers import get_demo_car_params -from openpilot.common.file_chunker import open_file_chunked from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params from openpilot.common.filter_simple import FirstOrderFilter @@ -42,7 +41,7 @@ from openpilot.sunnypilot.modeld_v2.meta_helper import load_meta_constants from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper from openpilot.sunnypilot.livedelay.helpers import get_lat_delay from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase -from openpilot.sunnypilot.modeld_v2.helpers import load_oob +from openpilot.sunnypilot.modeld_v2.helpers import load_model from openpilot.sunnypilot.modeld_v2.model_adapters import get_model_adapter from openpilot.sunnypilot.models.helpers import get_active_bundle from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController @@ -52,8 +51,7 @@ BIG_MODEL_TIMEOUT = 60 def _pkl_exists(path): - from openpilot.common.file_chunker import get_manifest_path - return os.path.exists(path) or os.path.exists(get_manifest_path(path)) + return os.path.exists(path) def _find_driving_pkl(bundle): @@ -108,7 +106,7 @@ class ModelState(ModelStateBase): def _init_combined(self, pkl_path, cam_w, cam_h, bundle): cloudlog.warning(f"loading combined pkl: {pkl_path}") - jits = load_oob(open_file_chunked(pkl_path)) + jits = load_model(pkl_path) metadata = jits['metadata'] self.WARP_DEV = metadata.get('warp_dev', 'QCOM') if COMMA_HARDWARE else 'CPU' @@ -258,7 +256,8 @@ def main(demo=False): CHESTNUT = chestnut_present() if CHESTNUT: - os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' + from tinygrad.runtime.ops_amd import AMDDevice + AMDDevice.wait_timeout_ms = 3000 params = Params() params.put_bool("ChestnutLoading", CHESTNUT) diff --git a/openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py b/openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py new file mode 100644 index 0000000000..05001cd670 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py @@ -0,0 +1,90 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import dataclasses +import mmap +import tempfile +from contextlib import contextmanager +from unittest import mock + +import numpy as np + +from openpilot.sunnypilot.modeld_v2.helpers import Blob, flatten + +# Compile time only/ see discountchubbs 4 assistance: turns a captured TinyJit into plain data that helpers.load_model rebuilds. It reads internals of the +# tinygrad that compiles the model, so a tinygrad bump that breaks it blocks new builds, never loading already built models. +@contextmanager +def record_kernel_linear(): + import tinygrad.engine.jit as jit + import tinygrad.engine.realize as realize + recorded, hcq_compile, compile_linear = [], realize.hcq_compile, jit.compile_linear + + def recording_compile_linear(*args, **kwargs): + with mock.patch.object(realize, "hcq_compile", lambda linear, *a, **k: (recorded.append(linear), hcq_compile(linear, *a, **k))[1]): + return compile_linear(*args, **kwargs) + + with mock.patch.object(jit, "compile_linear", recording_compile_linear): + yield recorded + + +def capture_frame(jit, kwargs: dict, output_container: str | None = None): + inputs = [([name] if key is None else [name, key], tensor.numpy().copy(), tensor.device) + for name, value in kwargs.items() for key, tensor in (value.items() if isinstance(value, dict) else [(None, value)])] + result = jit(**kwargs) + outputs = kwargs[output_container].values() if output_container else flatten(result) + return {"inputs": inputs, "outputs": [tensor.numpy().copy() for tensor in outputs], "output_container": output_container}, result + + +def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict, memoryview]: + from tinygrad.uop.ops import Ops + sink = tempfile.TemporaryFile(dir=".") + blob, bases, input_specs = Blob(sink), {}, {} + + def store(array) -> dict: + array = np.ascontiguousarray(array) + return {"data": blob.add(array.tobytes()), "dtype": array.dtype.str, "shape": list(array.shape)} + + def describe(uop) -> dict: + if uop.op is Ops.PARAM: + input_specs.setdefault(uop.arg.slot, {"dtype": repr(uop.dtype), "numel": int(uop.max_numel()), "device": uop.device}) + return {"input": uop.arg.slot} + buffer = uop.buffer + root = buffer._base if buffer._base is not None else buffer + base = bases.setdefault(id(root), (len(bases), root, uop.base.dtype if buffer._base is None else uop.dtype)) + return {"buffer": base[0], "offset": buffer.offset if buffer._base is not None else 0, "nbytes": buffer.nbytes, "dtype": repr(uop.dtype)} + + calls, written, needs_init = [], set(), set() + for call in kernel_linear.src: + ast, arguments = call.src[0], [describe(argument) for argument in call.src[1:]] + if ast.op is Ops.PROGRAM: + elf, info = ast.to_elf(), ast.arg + global_size, local_size = info.launch_dims({}) + calls.append({"kind": "program", "name": elf.name, "lib": blob.add(bytes(elf.lib)), "target": dataclasses.asdict(elf.target), + "signature": [[name, slot, repr(dtype), [int(x) for x in shape]] for name, slot, dtype, shape in elf.signature], + "global_size": [int(x) for x in global_size], "local_size": [int(x) for x in local_size], + "globals": list(info.globals), "outs": list(info.outs), "ins": list(info.ins), "args": arguments}) + outs, ins = info.outs, info.ins + elif ast.op is Ops.COPY: + calls.append({"kind": "copy", "args": arguments}) + outs, ins = (0,), (1,) + else: + raise ValueError(f"unsupported call {ast.op} {ast.arg}") + needs_init |= {arguments[i]["buffer"] for i in ins if "buffer" in arguments[i] and arguments[i]["buffer"] not in written} + written |= {arguments[i]["buffer"] for i in outs if "buffer" in arguments[i]} + + ret = jit.captured.ret + returns = {"kind": type(ret).__name__, "tensors": [describe(t.uop.base) | {"shape": [int(x) for x in t.shape]} for t in flatten(ret)]} + buffers = [{"nbytes": base.nbytes, "device": base.device, "dtype": repr(dtype)} + | ({"init": blob.add(base.as_memoryview())} if index in needs_init else {}) for index, base, dtype in bases.values()] + names = list(jit.captured.expected_names) + if check is not None: + check = {"inputs": [[path, store(array), device] for path, array, device in check["inputs"]], "outputs": [store(array) for array in check["outputs"]], + "output_container": check["output_container"]} + header = {"input_names": names, "buffers": buffers, "calls": calls, "returns": returns, "check": check, + "inputs": [{"slot": slot, "name": names[slot] if slot < len(names) else None} | input_specs[slot] for slot in sorted(input_specs)]} + with sink: + sink.flush() + return header, memoryview(mmap.mmap(sink.fileno(), 0, access=mmap.ACCESS_READ) if blob.size else b"") diff --git a/openpilot/sunnypilot/modeld_v2/tests/helpers.py b/openpilot/sunnypilot/modeld_v2/tests/helpers.py index 2dde6c19c6..9ab6af7afa 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/helpers.py +++ b/openpilot/sunnypilot/modeld_v2/tests/helpers.py @@ -188,10 +188,8 @@ def make_pkl_data(archetype): def write_pkl(tmp_path, archetype): - from openpilot.sunnypilot.modeld_v2.helpers import dump_oob pkl_path = tmp_path / 'driving_test_tinygrad.pkl' - with open(pkl_path, 'wb') as f: - dump_oob(make_pkl_data(archetype), f) + pkl_path.write_bytes(pickle.dumps(make_pkl_data(archetype))) return pkl_path @@ -211,6 +209,7 @@ def patch_modeld(monkeypatch): def _patch(bundle): monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None, *, chestnut=None: bundle) monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, chestnut=None: bundle) + monkeypatch.setattr(modeld_module, 'load_model', lambda path: pickle.loads(pathlib.Path(path).read_bytes())) return _patch diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py b/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py index 8b4c4e32a1..db0eaf8703 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py @@ -90,7 +90,6 @@ class TestStockEquivalence(OpenpilotTestCase): def test_unified_run_model(self, tmp_path, monkeypatch, patch_modeld): from openpilot.common.hardware import hw - from openpilot.sunnypilot.modeld_v2.helpers import dump_oob shapes = {'img': (1, 12, 128, 256), 'big_img': (1, 12, 128, 256), 'features_buffer': (1, 24, 32, 512), 'desire_pulse': (1, 25, 8), 'traffic_convention': (1, 2), 'action_t': (1, 2)} import codecs @@ -102,8 +101,7 @@ class TestStockEquivalence(OpenpilotTestCase): (CAM_W, CAM_H): tests_helpers._noop_jit, 'run_policy': tests_helpers._noop_jit, } - with open(tmp_path / 'driving_test_tinygrad.pkl', 'wb') as f: - dump_oob(pkl_data, f) + (tmp_path / 'driving_test_tinygrad.pkl').write_bytes(pickle.dumps(pkl_data)) bundle = DummyBundle(models=[DummyModel('supercombo', 'driving_test_tinygrad.pkl')]) patch_modeld(bundle) monkeypatch.setattr(hw.Paths, 'model_root', staticmethod(lambda: str(tmp_path))) diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py b/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py index 239c498e7d..348b4aca6a 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py @@ -5,16 +5,12 @@ This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ -import os -import tempfile import unittest -from pathlib import Path import numpy as np from openpilot.common.parameterized import parameterized -from openpilot.common.file_chunker import chunk_file, get_chunk_targets -from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, _detect_desire_key, read_file_chunked_to_disk +from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, _detect_desire_key from openpilot.common.test import OpenpilotTestCase @@ -168,36 +164,6 @@ class TestOutputSlicePreservation(OpenpilotTestCase): "vision and policy slices should not overlap in keys" -class TestReadFileChunkedToDisk(OpenpilotTestCase): - def test_none_passthrough(self): - assert read_file_chunked_to_disk(None) is None - - def test_unchunked_source_staged_on_disk(self): - with tempfile.TemporaryDirectory() as d: - src = Path(d) / "driving_supercombo.onnx" - payload = os.urandom(1024) - src.write_bytes(payload) - - out = Path(read_file_chunked_to_disk(str(src))) - - assert out.parent == Path(d) - assert out.name == "driving_supercombo.onnx.unchunked" - assert out.read_bytes() == payload - - def test_chunked_source_reassembled_on_disk(self): - with tempfile.TemporaryDirectory() as d: - src = Path(d) / "driving_supercombo.onnx" - payload = os.urandom(4096) - src.write_bytes(payload) - chunk_file(str(src), get_chunk_targets(str(src), len(payload))) - assert not src.exists() - - out = Path(read_file_chunked_to_disk(str(src))) - - assert out.parent == Path(d) - assert out.read_bytes() == payload - - class Test4DFeaturesBuffer(OpenpilotTestCase): def test_get_policy_npy_shapes_4d(self): from openpilot.sunnypilot.modeld_v2.compile_modeld import get_policy_npy_shapes diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py b/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py index 08054210f1..7967bf2f97 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py @@ -5,13 +5,11 @@ This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ -import io +import pickle import requests -from openpilot.common.file_chunker import get_chunk_name from openpilot.common.hardware import hw from openpilot.common.test import OpenpilotTestCase -from openpilot.sunnypilot.modeld_v2.helpers import dump_oob import openpilot.sunnypilot.modeld_v2.modeld as modeld_module from openpilot.sunnypilot.modeld_v2.tests import helpers as tests_helpers from openpilot.sunnypilot.modeld_v2.tests.helpers import DummyModel, DummyBundle, CAM_W, CAM_H @@ -43,14 +41,11 @@ class TestFallback(OpenpilotTestCase): small_json = requests.get(ModelFetcher.MODEL_URL).json() small_bundle = ModelParser.parse_models(small_json)[-1] - buf = io.BytesIO() - dump_oob(tests_helpers.make_pkl_data(tests_helpers.ARCHETYPES['supercombo_non20hz']), buf) - oob_bytes = buf.getvalue() + oob_bytes = pickle.dumps(tests_helpers.make_pkl_data(tests_helpers.ARCHETYPES['supercombo_non20hz'])) + monkeypatch.setattr(modeld_module, 'load_model', lambda path: pickle.loads(open(path, 'rb').read())) for bundle in (big_bundle, small_bundle): - artifact = bundle.models[0].artifact - for i in range(len(artifact.chunks)): - (tmp_path / get_chunk_name(artifact.fileName, i, len(artifact.chunks))).write_bytes(oob_bytes if i == 0 else b"") + (tmp_path / bundle.models[0].artifact.fileName).write_bytes(oob_bytes) monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, chestnut=None: small_bundle) assert modeld_module.ModelState(CAM_W, CAM_H, chestnut=False).chestnut is False diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_models.py b/openpilot/sunnypilot/modeld_v2/tests/test_models.py index 8f1cdedbd6..d24b629e77 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_models.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_models.py @@ -7,71 +7,14 @@ See the LICENSE.md file in the root directory for more details. import os import unittest -from unittest.mock import patch -from openpilot.common.file_chunker import open_file_chunked -from openpilot.sunnypilot.modeld_v2.helpers import load_oob -from tinygrad.device import Device + +from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, load_model -class TestLegacyModels(unittest.TestCase): - def test_legacy_model_load(self): - base_name = os.environ.get("MODEL_BASE_NAME") - if not base_name: - raise unittest.SkipTest("MODEL_BASE_NAME env var not set, skipping integration test.") - chunk_dir = os.environ.get("MODEL_CHUNK_DIR", "/tmp/model_chunks") - base_path = os.path.join(chunk_dir, base_name) - - try: - f = open_file_chunked(base_path) - except Exception as error: - self.fail(f"Failed to open chunked file {base_path}: {error}") - self.addCleanup(f.close) - - real_getitem = Device.__class__.__getitem__ - - def safe_getitem(device_self, ix): - if ix == "QCOM" and not os.path.exists("/dev/kgsl-3d0"): - return real_getitem(device_self, "CPU") - if ix == "AMD" and not os.path.exists("/dev/kfd"): - return real_getitem(device_self, "CPU") - return real_getitem(device_self, ix) - - with patch.object(Device.__class__, "__getitem__", safe_getitem): - obj = load_oob(f) - - assert isinstance(obj, dict), "Parsed object is not a dictionary" - assert "metadata" in obj, "Metadata key is missing" - - def traverse_jits(d): - if hasattr(d, "captured") and d.captured is not None: - linear_attr = getattr(d.captured, "_linear", None) - if linear_attr is None: - try: - linear_attr = getattr(d.captured, "linear", None) - except AssertionError as e: - if "alloc size must be positive" in str(e): - return - raise - - if linear_attr is not None: - stack = [linear_attr] - visited = set() - while stack: - node = stack.pop() - if id(node) in visited: - continue - visited.add(id(node)) - if hasattr(node, "src") and node.src is not None: - for child in node.src: - stack.append(child) - elif isinstance(d, dict): - for v in d.values(): - traverse_jits(v) - elif isinstance(d, (list, tuple)): - for v in d: - traverse_jits(v) - - if "run_model" in obj: - traverse_jits(obj["run_model"]) - if "run_policy" in obj: - traverse_jits(obj["run_policy"]) +class TestRegistryModels(unittest.TestCase): + def test_model_loads(self): + if not (base_name := os.environ.get("MODEL_BASE_NAME")): + self.skipTest("MODEL_BASE_NAME env var not set, skipping integration test.") + bundle = load_model(os.path.join(os.environ.get("MODEL_DIR", "/tmp/models"), base_name), {"QCOM": "CPU", "AMD": "CPU"}, verify=False, cache=False) + jits = [value for value in bundle.values() if isinstance(value, PlainJit)] + assert "metadata" in bundle and jits and all(len(jit.captured.linear.src) > 0 for jit in jits) diff --git a/openpilot/sunnypilot/modeld_v2/warmup.py b/openpilot/sunnypilot/modeld_v2/warmup.py new file mode 100755 index 0000000000..c4e12b83c1 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/warmup.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + +import os +os.environ['GMMU'] = '0' +import multiprocessing +import time +from openpilot.common.hardware.hw import Paths +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.modeld.helpers import chestnut_present +from openpilot.sunnypilot.modeld_v2.helpers import cache_is_fresh, load_model +from openpilot.sunnypilot.models.helpers import get_selected_bundle + + +def main(): + """ + The main entry point for offroad cache creation. + Runs every 10 seconds and checks for stale model caches. + Chestnut cache needs the gpu to build, so that slot waits until it is plugged in, or simply runs during modeld startup. + """ + params, failed, spawn = Params(), set(), multiprocessing.get_context("spawn") + while True: + for bundle in [get_selected_bundle(params, source) for source in (("qcom", "chestnut") if chestnut_present() else ("qcom",))]: + path = os.path.join(Paths.model_root(), bundle.models[0].artifact.fileName) if bundle and bundle.models else "" + if not os.path.isfile(path) or cache_is_fresh(path) or (attempt := (path, os.stat(path).st_mtime_ns)) in failed: + continue + (worker := spawn.Process(target=load_model, args=(path,), daemon=True)).start() + worker.join() + if not cache_is_fresh(path): + cloudlog.error(f"model cache warmup failed for {path} with exit code {worker.exitcode}") + failed.add(attempt) + time.sleep(10) + + +if __name__ == "__main__": + main() diff --git a/openpilot/sunnypilot/models/fetcher.py b/openpilot/sunnypilot/models/fetcher.py index 2a3fc14cb2..0c40c9e598 100644 --- a/openpilot/sunnypilot/models/fetcher.py +++ b/openpilot/sunnypilot/models/fetcher.py @@ -6,12 +6,10 @@ See the LICENSE.md file in the root directory for more details. """ import time -import os import requests from requests.exceptions import (SSLError, RequestException, HTTPError) from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog -from openpilot.common.hardware.hw import Paths from openpilot.sunnypilot.models.helpers import is_bundle_version_compatible from openpilot.cereal import custom @@ -26,35 +24,11 @@ class ModelParser: download_uri.sha256 = download_uri_data.get("sha256") return download_uri - @staticmethod - def _parse_chunk(chunk_data) -> custom.ModelManagerSP.Chunk: - chunk = custom.ModelManagerSP.Chunk() - chunk.fileName = chunk_data.get("file_name") - chunk.sha256 = chunk_data.get("sha256") - return chunk - @staticmethod def _parse_artifact(artifact_data) -> custom.ModelManagerSP.Artifact: artifact = custom.ModelManagerSP.Artifact() artifact.fileName = artifact_data.get("file_name") artifact.downloadUri = ModelParser._parse_download_uri(artifact_data.get("download_uri", {})) - - if "chunks" in artifact_data: - artifact.chunks = [ModelParser._parse_chunk(chunk_data) for chunk_data in artifact_data["chunks"]] - - try: - model_dir = Paths.model_root() - os.makedirs(model_dir, exist_ok=True) - manifest_path = os.path.join(model_dir, f"{artifact.fileName}.chunkmanifest") - num_chunks = str(len(artifact.chunks)) - - if not os.path.exists(manifest_path) or open(manifest_path).read().strip() != num_chunks: - with open(manifest_path, "w") as f: - f.write(num_chunks) - cloudlog.info(f"Wrote chunk manifest for {artifact.fileName}: {num_chunks} chunks") - except Exception as e: - cloudlog.warning(f"Failed to write chunk manifest for {artifact.fileName}: {e}") - return artifact @staticmethod @@ -138,8 +112,8 @@ class ModelCache: class ModelFetcher: """Handles fetching and caching of model data from remote source""" - MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v23.json" - MODEL_URL_CHESTNUT = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v27.json" + MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v25.json" + MODEL_URL_CHESTNUT = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v28.json" MODEL_SOURCES = { "qcom": (MODEL_URL, ""), @@ -270,5 +244,3 @@ if __name__ == "__main__": model_overrides = {override.key: override.value for override in bundle.overrides} print(f"Bundle: {bundle.internalName}, Type: {model.type}, Status: {bundle.status}, Overrides: {model_overrides}") print(f"Artifact: {model.artifact.fileName}, Download URI: {model.artifact.downloadUri.uri}") - if model.artifact.chunks: - print(f"Contains {len(model.artifact.chunks)} chunks.") diff --git a/openpilot/sunnypilot/models/helpers.py b/openpilot/sunnypilot/models/helpers.py index 49539c5745..0909f99bd1 100644 --- a/openpilot/sunnypilot/models/helpers.py +++ b/openpilot/sunnypilot/models/helpers.py @@ -16,7 +16,7 @@ from openpilot.common.hardware.hw import Paths from openpilot.selfdrive.modeld.helpers import chestnut_present # SET ME TO THE EXACT JSON VERSION WE SET IN SUNNYPILOT_MODELS REPO -REQUIRED_JSON_VERSION = 20 +REQUIRED_JSON_VERSION = 21 CUSTOM_MODEL_PATH = Paths.model_root() ModelManager = custom.ModelManagerSP @@ -29,9 +29,8 @@ _LAST_VALIDATED_RAW: dict[str, dict | None] = {} def _compute_hash(file_path: str) -> str | None: - from openpilot.common.file_chunker import open_file_chunked try: - with open_file_chunked(file_path) as file: + with open(file_path, 'rb') as file: return hashlib.file_digest(file, "sha256").hexdigest().lower() except FileNotFoundError: return None @@ -58,20 +57,11 @@ def is_bundle_version_compatible(bundle: dict) -> bool: def _bundle_artifacts(bundle: custom.ModelManagerSP.ModelBundle) -> list[tuple[str, str]]: artifacts = [] - from openpilot.common.file_chunker import get_chunk_name for model in getattr(bundle, 'models', []) or []: - for artifact in (getattr(model, 'artifact', None),): - if artifact and getattr(artifact, 'fileName', None): - if len(artifact.chunks) > 0: - for i, chunk in enumerate(artifact.chunks): - chunk_name = get_chunk_name(artifact.fileName, i, len(artifact.chunks)) - if getattr(chunk, 'sha256', None): - artifacts.append((chunk_name, chunk.sha256)) - else: - if getattr(artifact, 'downloadUri', None): - sha256 = getattr(artifact.downloadUri, 'sha256', None) - if sha256: - artifacts.append((artifact.fileName, sha256)) + artifact = getattr(model, 'artifact', None) + if artifact and getattr(artifact, 'fileName', None) and getattr(artifact, 'downloadUri', None): + if sha256 := getattr(artifact.downloadUri, 'sha256', None): + artifacts.append((artifact.fileName, sha256)) return artifacts diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index b313bccbc2..66b0fe28ab 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -109,50 +109,6 @@ class ModelManagerSP: # Clean up start time after download completes del self._download_start_times[model.fileName] - async def _download_chunked(self, base_url: str, base_path: str, artifact, skip: frozenset[int] | set[int] = frozenset()) -> None: - from openpilot.common.file_chunker import get_chunk_name, get_manifest_path - - num_chunks = len(artifact.chunks) - if num_chunks == 0: - raise ValueError("No chunks defined in artifact") - - manifest_path = get_manifest_path(base_path) - self._download_start_times[artifact.fileName] = time.monotonic() - - # Shared connection saves a TCP+TLS handshake per chunk. - # Keep sequential: the link saturates on one stream and Session is not thread-safe. - completed = len(skip) - with requests.Session() as session: - for i, _ in enumerate(artifact.chunks): - if i in skip: - continue - chunk_url = get_chunk_name(base_url, i, num_chunks) - chunk_path = get_chunk_name(base_path, i, num_chunks) - chunk_downloaded = 0 - with session.get(chunk_url, stream=True, timeout=DOWNLOAD_TIMEOUT) as response: - response.raise_for_status() - chunk_size = int(response.headers.get("content-length", 0)) - with open(chunk_path, 'wb') as f: # noqa: ASYNC230 - for data in response.iter_content(chunk_size=self._chunk_size): - f.write(data) - chunk_downloaded += len(data) - if self._download_interrupted(): - raise DownloadCancelled("Download cancelled") - intra = chunk_downloaded / max(chunk_size, 1) - progress = min(99.0, ((completed + intra) / num_chunks) * 100) - artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading - artifact.downloadProgress.progress = progress - artifact.downloadProgress.eta = self._calculate_eta(artifact.fileName, progress) - self._sync_artifact_progress(artifact) - self._report_status() - completed += 1 - - with open(manifest_path, 'w') as f: # noqa: ASYNC230 - f.write(str(num_chunks)) - if os.path.isfile(base_path): # noqa: ASYNC240 - os.remove(base_path) - del self._download_start_times[artifact.fileName] - async def _process_artifact(self, artifact, destination_path: str) -> None: if not artifact.downloadUri.uri: return None @@ -165,28 +121,7 @@ class ModelManagerSP: full_path = os.path.join(destination_path, filename) try: - # progress counts only valid chunks so a resumed download continues the - # bar from where verification left it, instead of falling back to zero - is_cached = False - valid_chunks: set[int] = set() - if len(artifact.chunks) > 0: - from openpilot.common.file_chunker import get_chunk_name - num_chunks = len(artifact.chunks) - for i, chunk in enumerate(artifact.chunks): - if self._download_interrupted(): - raise DownloadCancelled("Download cancelled") - if await verify_file(get_chunk_name(full_path, i, num_chunks), chunk.sha256): - valid_chunks.add(i) - artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.verifying - artifact.downloadProgress.progress = (len(valid_chunks) / num_chunks) * 100 - self._sync_artifact_progress(artifact) - self._report_status() - is_cached = len(valid_chunks) == num_chunks - else: - if await verify_file(full_path, expected_hash): - is_cached = True - - if is_cached: + if await verify_file(full_path, expected_hash): artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached artifact.downloadProgress.progress = 100 artifact.downloadProgress.eta = 0 @@ -194,17 +129,9 @@ class ModelManagerSP: self._report_status() return - if len(artifact.chunks) > 0: - await self._download_chunked(url, full_path, artifact, skip=valid_chunks) - from openpilot.common.file_chunker import get_chunk_name - for i, chunk in enumerate(artifact.chunks): - chunk_path = get_chunk_name(full_path, i, len(artifact.chunks)) - if not await verify_file(chunk_path, chunk.sha256): - raise ValueError(f"Hash validation failed for chunk {i+1} of {filename}") - else: - await self._download_file(url, full_path, artifact) - if not await verify_file(full_path, expected_hash): - raise ValueError(f"Hash validation failed for {filename}") + await self._download_file(url, full_path, artifact) + if not await verify_file(full_path, expected_hash): + raise ValueError(f"Hash validation failed for {filename}") artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloaded artifact.downloadProgress.progress = 100 @@ -213,7 +140,6 @@ class ModelManagerSP: self._report_status() except DownloadCancelled: - # a cancel keeps whatever is on disk: complete chunks resume the next attempt self._download_start_times.pop(artifact.fileName, None) artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed artifact.downloadProgress.eta = 0 @@ -225,9 +151,8 @@ class ModelManagerSP: except Exception as e: cloudlog.error(f"Error downloading {filename}: {str(e)}") - for f in [full_path] + [p for p in (os.path.join(destination_path, f) for f in os.listdir(destination_path)) if filename in p]: - if os.path.isfile(f): # noqa: ASYNC240 - os.remove(f) + if os.path.isfile(full_path): # noqa: ASYNC240 + os.remove(full_path) artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed artifact.downloadProgress.eta = 0 self._sync_artifact_progress(artifact) @@ -360,12 +285,11 @@ class ModelManagerSP: if model.artifact.fileName: active_files.append(model.artifact.fileName) - # Remove all files except active ones (including their chunk files) + # Remove all files except active ones model_dir = Paths.model_root() try: for filename in os.listdir(model_dir): - base = filename.split('.chunk')[0] if '.chunk' in filename else filename - if base not in active_files and filename not in active_files: + if filename not in active_files: file_path = os.path.join(model_dir, filename) if os.path.isfile(file_path): os.remove(file_path) diff --git a/openpilot/sunnypilot/models/tests/test_manager_download.py b/openpilot/sunnypilot/models/tests/test_manager_download.py index b995d4fbd1..0ae0273ec4 100644 --- a/openpilot/sunnypilot/models/tests/test_manager_download.py +++ b/openpilot/sunnypilot/models/tests/test_manager_download.py @@ -17,11 +17,9 @@ from typing import Any from unittest import mock import requests -from urllib3.connectionpool import HTTPConnectionPool from openpilot.cereal import custom from openpilot.common.test import OpenpilotTestCase -from openpilot.common.file_chunker import get_chunk_name, get_manifest_path from openpilot.selfdrive.test.helpers import http_server_context from openpilot.sunnypilot.models import manager as manager_module from openpilot.sunnypilot.models.fetcher import ModelFetcher, get_cached_bundles @@ -30,7 +28,6 @@ from openpilot.sunnypilot.models.helpers import (get_active_bundle, get_active_s resolve_bundle_by_ref, validate_active_bundles) from openpilot.sunnypilot.models.manager import ModelManagerSP -CHUNK_BODIES = [b'A' * 5000, b'B' * 5000, b'C' * 3000] WHOLE_BODY = b'Z' * 9000 @@ -49,12 +46,7 @@ class DownloadHandler(http.server.BaseHTTPRequestHandler): pass def _body_for(self, path): - if path.endswith('.whole'): - return WHOLE_BODY - for i in range(len(CHUNK_BODIES)): - if path.endswith(get_chunk_name('', i, len(CHUNK_BODIES))): - return CHUNK_BODIES[i] - return None + return WHOLE_BODY if path.endswith('.pkl') else None def do_GET(self): type(self).request_paths.append(self.path) @@ -119,35 +111,19 @@ class ManagerDownloadTestBase(OpenpilotTestCase): if artifact is not None: self.reported.append(float(artifact.downloadProgress.progress)) - def make_artifact(self, chunked: bool): + def make_artifact(self): bundle = custom.ModelManagerSP.ModelBundle.new_message() bundle.init('models', 1) artifact = bundle.models[0].artifact artifact.fileName = 'driving_test_tinygrad.pkl' - if chunked: - artifact.downloadUri.uri = self.base_url + '/driving_test_tinygrad.pkl' - artifact.downloadUri.sha256 = sha256(b''.join(CHUNK_BODIES)) - artifact.init('chunks', len(CHUNK_BODIES)) - for i, body in enumerate(CHUNK_BODIES): - artifact.chunks[i].sha256 = sha256(body) - else: - artifact.downloadUri.uri = self.base_url + '/driving_test_tinygrad.pkl.whole' - artifact.downloadUri.sha256 = sha256(WHOLE_BODY) + artifact.downloadUri.uri = self.base_url + '/driving_test_tinygrad.pkl' + artifact.downloadUri.sha256 = sha256(WHOLE_BODY) self._bundle = bundle self.artifact = artifact return artifact - def chunk_paths(self, base_path): - return [get_chunk_name(base_path, i, len(CHUNK_BODIES)) for i in range(len(CHUNK_BODIES))] - - def assert_no_partials(self, base_path): - leftovers = [p for p in [base_path, get_manifest_path(base_path)] + self.chunk_paths(base_path) - if os.path.isfile(p)] - assert leftovers == [], f"partial files left behind: {leftovers}" - - class TestManagerDownload(ManagerDownloadTestBase): - """Exercises the real _download_file / _download_chunked against a local server.""" + """Exercises the real _download_file against a local server.""" def run_with_server(self, fn): with http_server_context(handler=DownloadHandler) as (host, port): @@ -156,7 +132,7 @@ class TestManagerDownload(ManagerDownloadTestBase): def test_download_file_writes_exact_bytes(self): def body(): - artifact = self.make_artifact(chunked=False) + artifact = self.make_artifact() path = os.path.join(self.dest, artifact.fileName) asyncio.run(self.manager._download_file(artifact.downloadUri.uri, path, artifact)) with open(path, 'rb') as f: @@ -166,111 +142,67 @@ class TestManagerDownload(ManagerDownloadTestBase): assert artifact.fileName not in self.manager._download_start_times self.run_with_server(body) - def test_download_chunked_writes_all_chunks_and_manifest(self): - def body(): - artifact = self.make_artifact(chunked=True) - base_path = os.path.join(self.dest, artifact.fileName) - asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) - - for i, expected in enumerate(CHUNK_BODIES): - with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'rb') as f: - assert f.read() == expected, f"chunk {i} body mismatch" - - with open(get_manifest_path(base_path)) as f: - assert f.read() == str(len(CHUNK_BODIES)) - - assert not os.path.isfile(base_path), "base file should be removed after chunking" - assert artifact.fileName not in self.manager._download_start_times - self.run_with_server(body) - def test_progress_is_monotonic_and_bounded(self): def body(): - artifact = self.make_artifact(chunked=True) + artifact = self.make_artifact() base_path = os.path.join(self.dest, artifact.fileName) - asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) assert self.reported, "expected progress reports" for a, b in zip(self.reported, self.reported[1:], strict=False): assert b >= a, f"progress went backwards: {a} -> {b}" - assert max(self.reported) <= 99.0, f"chunked progress must stay <=99 until verify, got {max(self.reported)}" - self.run_with_server(body) - - def test_session_is_reused_across_chunks(self): - """One connection pool shared across every chunk.""" - def body(): - artifact = self.make_artifact(chunked=True) - base_path = os.path.join(self.dest, artifact.fileName) - - pools = [] - original = HTTPConnectionPool.urlopen - - def tracked(pool_self, *args, **kwargs): - pools.append(id(pool_self)) - return original(pool_self, *args, **kwargs) - - with mock.patch.object(HTTPConnectionPool, 'urlopen', tracked): - asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) - - assert len(pools) == len(CHUNK_BODIES), f"expected one request per chunk, got {len(pools)}" - assert len(set(pools)) == 1, f"connection pool not reused across chunks: {len(set(pools))} pools" self.run_with_server(body) def test_http_error_propagates(self): def body(): - artifact = self.make_artifact(chunked=True) + artifact = self.make_artifact() base_path = os.path.join(self.dest, artifact.fileName) - failing = '/' + os.path.basename(get_chunk_name(artifact.downloadUri.uri, 1, len(CHUNK_BODIES))) - DownloadHandler.fail_paths = {failing: 404} + DownloadHandler.fail_paths = {'/' + artifact.fileName: 404} with self.assertRaises(requests.exceptions.HTTPError): - asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) - - # chunk 1 failed, so its file and the manifest must not exist - assert not os.path.isfile(get_chunk_name(base_path, 1, len(CHUNK_BODIES))) - assert not os.path.isfile(get_manifest_path(base_path)) + asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) + assert not os.path.isfile(base_path) self.run_with_server(body) def test_cancellation_mid_transfer(self): """Cancellation is checked inside the byte loop; it must still fire after the port.""" def body(): - artifact = self.make_artifact(chunked=True) + artifact = self.make_artifact() base_path = os.path.join(self.dest, artifact.fileName) self.manager.params.get.return_value = None # cancelled with self.assertRaises(Exception) as ctx: - asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) assert 'cancelled' in str(ctx.exception).lower() - assert not os.path.isfile(get_manifest_path(base_path)) self.run_with_server(body) def test_repeat_downloads_are_stable(self): """Back-to-back runs must produce identical bytes and leak no start-time state.""" def body(): for _ in range(2): - artifact = self.make_artifact(chunked=True) + artifact = self.make_artifact() base_path = os.path.join(self.dest, artifact.fileName) - asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) - for i, expected in enumerate(CHUNK_BODIES): - with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'rb') as f: - assert f.read() == expected + asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) + with open(base_path, 'rb') as f: + assert f.read() == WHOLE_BODY assert self.manager._download_start_times == {} self.run_with_server(body) def test_download_ref_present_keeps_download_alive(self): """A pending download request (DownloadRef set) must not be cancelled mid-transfer.""" def body(): - artifact = self.make_artifact(chunked=True) + artifact = self.make_artifact() base_path = os.path.join(self.dest, artifact.fileName) self.manager.params.get.side_effect = lambda key: b"ref" if key == "ModelManager_DownloadRef" else None self.manager._download_ref = b"ref" - asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) - assert os.path.isfile(get_manifest_path(base_path)) + asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) + assert os.path.isfile(base_path) self.run_with_server(body) def test_cancellation_via_download_ref(self): """Removing DownloadRef mid-transfer cancels the download.""" def body(): - artifact = self.make_artifact(chunked=True) + artifact = self.make_artifact() base_path = os.path.join(self.dest, artifact.fileName) checks = {"n": 0} @@ -283,20 +215,19 @@ class TestManagerDownload(ManagerDownloadTestBase): self.manager.params.get.side_effect = get self.manager._download_ref = b"ref" with self.assertRaises(Exception) as ctx: - asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) assert 'cancelled' in str(ctx.exception).lower() - assert not os.path.isfile(get_manifest_path(base_path)) self.run_with_server(body) def test_replaced_download_ref_queues_instead_of_cancelling(self): """Selecting another model mid-transfer lets the running download finish.""" def body(): - artifact = self.make_artifact(chunked=True) + artifact = self.make_artifact() base_path = os.path.join(self.dest, artifact.fileName) self.manager.params.get.side_effect = lambda key: b"other-ref" if key == "ModelManager_DownloadRef" else None self.manager._download_ref = b"ref" - asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) - assert os.path.isfile(get_manifest_path(base_path)) + asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) + assert os.path.isfile(base_path) self.run_with_server(body) def test_replaced_download_ref_is_kept(self): @@ -315,11 +246,10 @@ class TestManagerDownload(ManagerDownloadTestBase): def test_cached_bundle_cancel_skips_slot_write(self): """A cancel must stop an already-on-disk bundle before it is applied to the slot.""" def body(): - artifact = self.make_artifact(chunked=True) + artifact = self.make_artifact() base_path = os.path.join(self.dest, artifact.fileName) - for i, data in enumerate(CHUNK_BODIES): - with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'wb') as f: - f.write(data) + with open(base_path, 'wb') as f: + f.write(WHOLE_BODY) self._bundle.ref = "test-ref" params, store = self._make_params_with_store() store["ModelManager_DownloadRef"] = None # removed -> cancelled @@ -329,42 +259,7 @@ class TestManagerDownload(ManagerDownloadTestBase): asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "qcom")) assert 'cancelled' in str(ctx.exception).lower() assert "ModelManager_ActiveBundle" not in store - assert all(os.path.isfile(p) for p in self.chunk_paths(base_path)), "cancel must not delete cached chunks" - self.run_with_server(body) - - def test_resume_skips_valid_chunks(self): - """A chunk already on disk is kept and not re-downloaded; progress starts above its share.""" - def body(): - artifact = self.make_artifact(chunked=True) - base_path = os.path.join(self.dest, artifact.fileName) - with open(get_chunk_name(base_path, 0, len(CHUNK_BODIES)), 'wb') as f: - f.write(CHUNK_BODIES[0]) - - asyncio.run(self.manager._process_artifact(artifact, self.dest)) - - chunk0_suffix = get_chunk_name('', 0, len(CHUNK_BODIES)) - assert not any(p.endswith(chunk0_suffix) for p in DownloadHandler.request_paths), "valid chunk was re-downloaded" - for i, expected in enumerate(CHUNK_BODIES): - with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'rb') as f: - assert f.read() == expected - assert os.path.isfile(get_manifest_path(base_path)) - assert min(self.reported) >= (1 / len(CHUNK_BODIES)) * 100 - 1, "progress must not restart below the resumed share" - self.run_with_server(body) - - def test_verify_reports_valid_fraction_then_cached(self): - """A fully cached bundle publishes climbing verify progress and ends cached.""" - def body(): - artifact = self.make_artifact(chunked=True) - base_path = os.path.join(self.dest, artifact.fileName) - for i, data in enumerate(CHUNK_BODIES): - with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'wb') as f: - f.write(data) - - asyncio.run(self.manager._process_artifact(artifact, self.dest)) - - assert DownloadHandler.request_paths == [], "cached bundle must not hit the network" - assert [round(p) for p in self.reported[:3]] == [33, 67, 100] - assert artifact.downloadProgress.status == custom.ModelManagerSP.DownloadStatus.cached + assert os.path.isfile(base_path), "cancel must not delete the cached model" self.run_with_server(body) def _make_params_with_store(self): @@ -384,7 +279,7 @@ class TestManagerDownload(ManagerDownloadTestBase): def test_download_writes_qcom_slot(self): """A download resolved to the qcom source writes the qcom active bundle slot only.""" def body(): - artifact = self.make_artifact(chunked=True) + artifact = self.make_artifact() self._bundle.ref = "test-ref" self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION params, store = self._make_params_with_store() @@ -396,15 +291,13 @@ class TestManagerDownload(ManagerDownloadTestBase): assert self.manager.selected_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded assert self.manager.active_bundle is not None and self.manager.active_bundle.ref == "test-ref" assert self.manager.active_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded - chunk_names = [get_chunk_name(artifact.fileName, i, len(artifact.chunks)) for i in range(len(artifact.chunks))] - missing = [c for c in chunk_names if not os.path.isfile(os.path.join(self.dest, c))] - assert missing == [], f"chunks missing from the cache: {missing}" + assert os.path.isfile(os.path.join(self.dest, artifact.fileName)) self.run_with_server(body) def test_download_writes_chestnut_slot(self): """A download resolved to the chestnut source writes the chestnut active bundle slot only.""" def body(): - self.make_artifact(chunked=True) + self.make_artifact() self._bundle.ref = "big-ref" self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION params, store = self._make_params_with_store() @@ -782,7 +675,7 @@ class TestEffectiveSource(OpenpilotTestCase): @unittest.skipUnless(os.environ.get('RUN_INTEGRATION_TESTS'), 'requires external network') class TestLiveModelManifest(OpenpilotTestCase): - """Every artifact and chunk URL in the published manifest must resolve.""" + """Every artifact URL in the published manifest must resolve.""" def test_all_manifest_urls_available(self): from openpilot.sunnypilot.models.fetcher import ModelFetcher @@ -794,10 +687,7 @@ class TestLiveModelManifest(OpenpilotTestCase): for bundle in manifest.get('bundles', []): for model in bundle.get('models', []): artifact = model['artifact'] - url = artifact['download_uri']['url'] - chunks = artifact.get('chunks', []) - urls = ([url] if not chunks - else [get_chunk_name(url, i, len(chunks)) for i in range(len(chunks))]) + urls = [artifact['download_uri']['url']] for u in urls: try: r = session.head(u, timeout=15, allow_redirects=True) diff --git a/openpilot/system/manager/process_config.py b/openpilot/system/manager/process_config.py index c95b110539..8959016844 100644 --- a/openpilot/system/manager/process_config.py +++ b/openpilot/system/manager/process_config.py @@ -175,6 +175,7 @@ procs += [ # Models PythonProcess("models_manager", "openpilot.sunnypilot.models.manager", only_offroad), NativeProcess("modeld_tinygrad", "openpilot/sunnypilot/modeld_v2", ["./modeld"], and_(only_onroad, is_tinygrad_model)), + PythonProcess("modeld_warmup", "openpilot.sunnypilot.modeld_v2.warmup", only_offroad), # Backup PythonProcess("backup_manager", "openpilot.sunnypilot.sunnylink.backups.manager", and_(only_offroad, sunnylink_ready_shim)), diff --git a/release/ci/model_generator.py b/release/ci/model_generator.py index 3b5603c4ae..5d0ca73644 100755 --- a/release/ci/model_generator.py +++ b/release/ci/model_generator.py @@ -54,18 +54,10 @@ def create_pkl_name(full_name: str) -> str: def _hash_pkl(pkl_path: Path) -> str: - manifest = Path(f"{pkl_path}.chunkmanifest") - if manifest.exists(): - num_chunks = int(manifest.read_text().strip()) - paths = [Path(f"{pkl_path}.chunk{i + 1:02d}of{num_chunks:02d}") for i in range(num_chunks)] - else: - paths = [pkl_path] - digest = hashlib.sha256() - for path in paths: - with path.open('rb') as f: - while block := f.read(1024 * 1024): - digest.update(block) + with pkl_path.open('rb') as f: + while block := f.read(1024 * 1024): + digest.update(block) return digest.hexdigest() @@ -74,22 +66,9 @@ def _find_driving_pkl(output_path: Path) -> Path | None: matches = sorted(output_path.glob(pattern)) if matches: return matches[0] - for pattern in ('*driving_tinygrad.pkl.chunkmanifest', '*driving_*_tinygrad.pkl.chunkmanifest'): - matches = sorted(output_path.glob(pattern)) - if matches: - return Path(str(matches[0]).removesuffix('.chunkmanifest')) return None -def _rename_pkl_with_chunks(old_pkl: Path, new_pkl: Path) -> Path: - manifest = Path(f"{old_pkl}.chunkmanifest") - if manifest.exists(): - for f in sorted(old_pkl.parent.glob(f"{old_pkl.name}.chunk*")): - f.rename(old_pkl.parent / f.name.replace(old_pkl.name, new_pkl.name, 1)) - return new_pkl - return old_pkl.rename(new_pkl) - - def _hash_onnx_files(model_dir: Path) -> str | None: onnx_files = [f for f in sorted(model_dir.glob("*.onnx")) if f.stat().st_size > 1024] if not onnx_files: @@ -102,22 +81,9 @@ def _hash_onnx_files(model_dir: Path) -> str | None: return digest.hexdigest() -def generate_chunked_model(driving_pkl: Path) -> dict: +def generate_model(driving_pkl: Path) -> dict: tinygrad_hash = _hash_pkl(driving_pkl) - chunks_config = [] - manifest_file = Path(f"{driving_pkl}.chunkmanifest") - if manifest_file.exists(): - num_chunks = int(manifest_file.read_text().strip()) - for i in range(num_chunks): - chunk_path = Path(f"{driving_pkl}.chunk{i + 1:02d}of{num_chunks:02d}") - if chunk_path.exists(): - chunk_hash = hashlib.sha256(chunk_path.read_bytes()).hexdigest() - chunks_config.append({ - "file_name": chunk_path.name, - "sha256": chunk_hash - }) - artifact_data = { "file_name": driving_pkl.name, "download_uri": { @@ -126,9 +92,6 @@ def generate_chunked_model(driving_pkl: Path) -> dict: } } - if chunks_config: - artifact_data["chunks"] = chunks_config - return { "type": "chunked", "artifact": artifact_data, @@ -193,11 +156,11 @@ if __name__ == "__main__": if _pkl: new_pkl = _output_dir / f"driving_{_pkl}_tinygrad.pkl" if not new_pkl.exists(): - _driving_pkl = _rename_pkl_with_chunks(_driving_pkl, new_pkl) + _driving_pkl = _driving_pkl.rename(new_pkl) else: _driving_pkl = new_pkl - _model_metadata = generate_chunked_model(_driving_pkl) + _model_metadata = generate_model(_driving_pkl) _onnx_sha256 = args.onnx_sha256 or _hash_onnx_files(Path(args.model_dir)) create_metadata_json([_model_metadata], _output_dir, args.custom_name, _short_name, args.is_20hz, args.upstream_branch, onnx_sha256=_onnx_sha256, is_big=is_big) diff --git a/release/ci/upload_default_model.py b/release/ci/upload_default_model.py index 2abd5087c0..8fce46a381 100644 --- a/release/ci/upload_default_model.py +++ b/release/ci/upload_default_model.py @@ -79,8 +79,6 @@ def main(): artifact = bundle['models'][0]['artifact'] hf_base = f"https://huggingface.co/datasets/{args.hf_repo}/resolve/main/{args.hf_defaults_path}/{folder_name}" artifact['download_uri']['url'] = f"{hf_base}/{artifact['file_name']}" - for chunk in artifact.get('chunks', []): - chunk['url'] = f"{hf_base}/{chunk['file_name']}" print(f"Uploading model to {args.hf_defaults_path}/{folder_name}/") api.upload_folder( diff --git a/tinygrad_repo b/tinygrad_repo index fe5d3169ba..69fd4d34d4 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit fe5d3169ba4f41d0947ad174925f413cbea9d056 +Subproject commit 69fd4d34d4430036378aba2b43f9d339cdd6ea73 From e6864661c986f395a92c06674dab407ba35030f9 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:35:54 -0400 Subject: [PATCH 258/270] modeld_v2: create a compiled pkl -> gpu kernel artifact converter (#2053) --- .github/workflows/compile-warp-kernels.yaml | 10 +- .github/workflows/sunnypilot-build-model.yaml | 117 ++++------ .../sunnypilot/modeld_v2/compile_modeld.py | 21 +- .../sunnypilot/modeld_v2/compile_warp.py | 43 ++++ .../modeld_v2/convert_upstream_pkl.py | 214 ++++++++++++++++++ .../sunnypilot/modeld_v2/frame_fusion.py | 69 ++++++ openpilot/sunnypilot/modeld_v2/helpers.py | 81 +++++-- .../sunnypilot/modeld_v2/model_adapters.py | 45 ++-- openpilot/sunnypilot/modeld_v2/modeld.py | 33 ++- .../big_driving_warp_1344x760_tinygrad.pkl | 4 +- .../big_driving_warp_1928x1208_tinygrad.pkl | 4 +- .../models/driving_warp_1344x760_tinygrad.pkl | 4 +- .../driving_warp_1928x1208_tinygrad.pkl | 4 +- .../modeld_v2/plain_artifact_compiler.py | 2 +- .../sunnypilot/modeld_v2/tests/helpers.py | 2 +- .../modeld_v2/tests/test_fallback.py | 2 +- openpilot/sunnypilot/modeld_v2/warmup.py | 28 +-- 17 files changed, 506 insertions(+), 177 deletions(-) create mode 100755 openpilot/sunnypilot/modeld_v2/compile_warp.py create mode 100755 openpilot/sunnypilot/modeld_v2/convert_upstream_pkl.py create mode 100644 openpilot/sunnypilot/modeld_v2/frame_fusion.py diff --git a/.github/workflows/compile-warp-kernels.yaml b/.github/workflows/compile-warp-kernels.yaml index 15fb1057f7..89205b622f 100644 --- a/.github/workflows/compile-warp-kernels.yaml +++ b/.github/workflows/compile-warp-kernels.yaml @@ -2,8 +2,6 @@ name: Compile warp for sunnypilot modeld on: workflow_dispatch: - schedule: - - cron: '0 0 * * 0' jobs: compile_warps: @@ -43,13 +41,13 @@ jobs: WARP_PKL="openpilot/sunnypilot/modeld_v2/models/driving_warp_${res}_tinygrad.pkl" echo "Compiling $WARP_PKL on QCOM" - taskset -c 7 env DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1 python3 tinygrad_repo/examples/openpilot/compile_warp.py \ - --frame ${FRAME_PARAM} --warp-to ${MODEL_SIZE} --layout yuv420 --frames 2 --output "${WARP_PKL}" + taskset -c 7 env DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1 python3 openpilot/sunnypilot/modeld_v2/compile_warp.py \ + --frame ${FRAME_PARAM} --warp-to ${MODEL_SIZE} --output "${WARP_PKL}" BIG_WARP_PKL="openpilot/sunnypilot/modeld_v2/models/big_driving_warp_${res}_tinygrad.pkl" echo "Compiling $BIG_WARP_PKL on AMD" - taskset -c 7 env DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32 python3 tinygrad_repo/examples/openpilot/compile_warp.py \ - --frame ${FRAME_PARAM} --warp-to ${MODEL_SIZE} --layout yuv420 --frames 2 --output "${BIG_WARP_PKL}" + taskset -c 7 env DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32 python3 openpilot/sunnypilot/modeld_v2/compile_warp.py \ + --frame ${FRAME_PARAM} --warp-to ${MODEL_SIZE} --output "${BIG_WARP_PKL}" done - name: Re-enable powersave diff --git a/.github/workflows/sunnypilot-build-model.yaml b/.github/workflows/sunnypilot-build-model.yaml index cf28033a58..8276c40136 100644 --- a/.github/workflows/sunnypilot-build-model.yaml +++ b/.github/workflows/sunnypilot-build-model.yaml @@ -102,66 +102,39 @@ jobs: cat $GITHUB_OUTPUT - name: Fetch Models - env: - HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | cd ${{ github.workspace }}/openpilot/openpilot + fetch_lfs() { + sudo apt-get install -y -qq aria2 > /dev/null # Parallel ranges because gh is flaky + LFS_URL=$(git config -f "$(git rev-parse --show-toplevel)/.lfsconfig" lfs.url) + for f in "$@"; do + grep -qs "^version https://git-lfs" "$f" || continue + OID=$(awk -F'sha256:' '/^oid/{print $2}' "$f"); SIZE=$(awk '/^size/{print $2}' "$f") + HREF=$(curl -fsSL --retry 3 -X POST -H "Accept: application/vnd.git-lfs+json" -H "Content-Type: application/vnd.git-lfs+json" "$LFS_URL/objects/batch" \ + -d "{\"operation\":\"download\",\"transfer\":[\"basic\"],\"objects\":[{\"oid\":\"$OID\",\"size\":$SIZE}]}" | jq -r '.objects[0].actions.download.href') + aria2c -x 16 -s 16 -k 16M --max-tries=5 --retry-wait=5 --file-allocation=none --summary-interval=30 --allow-overwrite=true \ + -d "$(dirname "$f")" -o "$(basename "$f")" "$HREF" + echo "$OID $f" | sha256sum -c - + done + } + UPSTREAM_PKL=selfdrive/modeld/models/big_driving_tinygrad.pkl if [ "${{ inputs.target_hardware }}" != "chestnut" ]; then - ONNX_COUNT=$(find selfdrive/modeld/models -maxdepth 1 -type f -name "*.onnx" ! -name "big_*.onnx" ! -name "dmonitoring_*.onnx" 2>/dev/null | wc -l) + git lfs pull -X "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/dmonitoring_*.onnx,**/selfdrive/modeld/models/big_*.pkl,**/selfdrive/modeld/models/dmonitoring_*.pkl" + rm -f selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/dmonitoring_*.onnx selfdrive/modeld/models/big_*.pkl selfdrive/modeld/models/dmonitoring_*.pkl + elif compgen -G "selfdrive/modeld/models/big_*.onnx" > /dev/null; then + fetch_lfs selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/big_*.pkl + find selfdrive/modeld/models -type f \( -name "*.onnx" -o -name "*.pkl" \) ! -name "big_*.onnx" ! -name "big_*.pkl" -delete + elif [ -f "$UPSTREAM_PKL" ]; then + echo "No big model ONNX tracked: converting commaai's precompiled $UPSTREAM_PKL, with the tinygrad it was built with" + fetch_lfs "$UPSTREAM_PKL" + find selfdrive/modeld/models -type f \( -name "*.onnx" -o -name "*.pkl" \) ! -path "$UPSTREAM_PKL" -delete + GITMODULES="$(git rev-parse --show-toplevel)/.gitmodules" + TG_NAME=$(git config -f "$GITMODULES" --get-regexp '^submodule\..*\.path$' | awk '$2 == "tinygrad_repo" {sub(/^submodule\./, "", $1); sub(/\.path$/, "", $1); print $1}') + TG_URL=$(git config -f "$GITMODULES" "submodule.$TG_NAME.url" | sed 's|^\.\./\.\./|https://github.com/|') + echo "$TG_URL $(git rev-parse HEAD:tinygrad_repo) $(sha256sum "$UPSTREAM_PKL" | cut -d' ' -f1)" > selfdrive/modeld/models/upstream_pkl.txt else - ONNX_COUNT=$(find selfdrive/modeld/models -maxdepth 1 -type f -name "big_*.onnx" 2>/dev/null | wc -l) - fi - if [ "$ONNX_COUNT" -eq 0 ]; then - echo "Detected NEW stock architecture (no ONNX files tracked). Fetching from HF and skipping LFS pull." - COMMIT_MSG=$(git log -1 --pretty=%B) - HF_HASH=$(echo "$COMMIT_MSG" | grep -oE '[a-f0-9]{8}' | head -n 1) - - if [ -n "$HF_HASH" ]; then - echo "Downloading ONNX for hash $HF_HASH from HuggingFace." - pip install huggingface_hub - rm -f selfdrive/modeld/models/*.onnx - python3 - < /dev/null # Parallel ranges because gh is flaky - LFS_URL=$(git config -f "$(git rev-parse --show-toplevel)/.lfsconfig" lfs.url) - for f in selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/big_*.pkl; do - grep -qs "^version https://git-lfs" "$f" || continue - OID=$(awk -F'sha256:' '/^oid/{print $2}' "$f"); SIZE=$(awk '/^size/{print $2}' "$f") - HREF=$(curl -fsSL --retry 3 -X POST -H "Accept: application/vnd.git-lfs+json" -H "Content-Type: application/vnd.git-lfs+json" "$LFS_URL/objects/batch" \ - -d "{\"operation\":\"download\",\"transfer\":[\"basic\"],\"objects\":[{\"oid\":\"$OID\",\"size\":$SIZE}]}" | jq -r '.objects[0].actions.download.href') - aria2c -x 16 -s 16 -k 16M --max-tries=5 --retry-wait=5 --file-allocation=none --summary-interval=30 --allow-overwrite=true \ - -d "$(dirname "$f")" -o "$(basename "$f")" "$HREF" - echo "$OID $f" | sha256sum -c - - done - find selfdrive/modeld/models -type f \( -name "*.onnx" -o -name "*.pkl" \) ! -name "big_*.onnx" ! -name "big_*.pkl" -delete - fi + echo "::error::no big model ONNX or precompiled big_driving_tinygrad.pkl at this ref" + exit 1 fi if grep -lIF "version https://git-lfs.github.com/spec/v1" selfdrive/modeld/models/*.onnx selfdrive/modeld/models/*.pkl 2>/dev/null; then @@ -176,7 +149,7 @@ jobs: path: | ${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/*.onnx ${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/*.pkl - ${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/new_arch.txt + ${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/upstream_pkl.txt if-no-files-found: error build_model: @@ -249,21 +222,20 @@ jobs: NATIVE_PKL=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "*.pkl" -print -quit) - if [ -f "${{ env.MODELS_DIR }}/new_arch.txt" ]; then - echo "Detected new arch" - if [ "${{ inputs.target_hardware }}" == "chestnut" ]; then - DOWNLOADED_ONNX=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "big_*driving*.onnx" -print -quit) - else - DOWNLOADED_ONNX=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "*driving*.onnx" ! -name "big_*" -print -quit) - fi - if [ -n "$DOWNLOADED_ONNX" ]; then - echo "Compiling ONNX: $DOWNLOADED_ONNX" - env ${TG_FLAGS} taskset -c 7 python3 openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py \ - "$DOWNLOADED_ONNX" "$OUTPUT_PKL" --device-input "*" --benchmark-runs 1 - else - echo "Failed to find downloaded ONNX." - exit 1 - fi + if [ -f "${{ env.MODELS_DIR }}/upstream_pkl.txt" ]; then + read -r TG_URL TG_SHA _ < "${{ env.MODELS_DIR }}/upstream_pkl.txt" + echo "Converting commaai's precompiled model with tinygrad $TG_SHA" + UPSTREAM_TG="${{ runner.temp }}/upstream_tinygrad" + rm -rf "$UPSTREAM_TG" && git init -q "$UPSTREAM_TG" && git -C "$UPSTREAM_TG" fetch -q --depth 1 "$TG_URL" "$TG_SHA" && git -C "$UPSTREAM_TG" checkout -q FETCH_HEAD + CONVERTED="${{ runner.temp }}/big_driving_tinygrad_plain.pkl" + env DEV=USB+AMD PYTHONPATH="$UPSTREAM_TG:${{ github.workspace }}" taskset -c 7 python3 openpilot/sunnypilot/modeld_v2/convert_upstream_pkl.py \ + "${{ env.MODELS_DIR }}/big_driving_tinygrad.pkl" "$CONVERTED" + mv "$CONVERTED" "$OUTPUT_PKL" + elif NEW_ARCH_ONNX=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "${{ inputs.target_hardware == 'chestnut' && 'big_' || '' }}*driving*.onnx" -print -quit) && + [ -n "$NEW_ARCH_ONNX" ] && grep -qa new_img "$NEW_ARCH_ONNX"; then # the new architecture: one ONNX taking the warped frames as new_img + echo "Compiling new architecture ONNX: $NEW_ARCH_ONNX" + env ${TG_FLAGS} taskset -c 7 python3 openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py \ + "$NEW_ARCH_ONNX" "$OUTPUT_PKL" --device-input "*" --benchmark-runs 1 else echo "Detected onnx architecture. Routing to native compile_modeld.py." # Detect model type and build compile args @@ -340,6 +312,7 @@ jobs: --output-dir "${{ env.OUTPUT_DIR }}" \ --custom-name "${{ env.MODEL_NAME }}" \ --upstream-branch "${{ inputs.upstream_branch }}" \ + $([ -f "${{ env.MODELS_DIR }}/upstream_pkl.txt" ] && echo "--onnx-sha256 $(cut -d' ' -f3 "${{ env.MODELS_DIR }}/upstream_pkl.txt")") \ ${{ inputs.is_20hz && '--is-20hz' || '' }} - name: Write artifact name to file diff --git a/openpilot/sunnypilot/modeld_v2/compile_modeld.py b/openpilot/sunnypilot/modeld_v2/compile_modeld.py index ab338f3ad5..732c2d54a1 100755 --- a/openpilot/sunnypilot/modeld_v2/compile_modeld.py +++ b/openpilot/sunnypilot/modeld_v2/compile_modeld.py @@ -20,7 +20,7 @@ from typing import Any import numpy as np -from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, read_parts, write_bundle +from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, patch_fetch_fw, read_parts, write_bundle from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit import openpilot.sunnypilot.modeld_v2.stock_dependencies as stock from openpilot.system.camerad.cameras.nv12_info import get_nv12_info @@ -34,24 +34,7 @@ from tinygrad.tensor import Tensor os.environ['GMMU'] = '0' -def _patch_tinygrad_fetch_fw(): - try: - import hashlib - import pathlib - import zstandard - from tinygrad import helpers - _orig_fetch_fw = helpers.fetch_fw - def fetch_fw(path, name, sha256): - p = pathlib.Path(f"/lib/firmware/{path}/{name}.zst") - if p.is_file(): - blob = zstandard.ZstdDecompressor().stream_reader(p.read_bytes()).read() - if hashlib.sha256(blob).hexdigest() == sha256: - return blob - return _orig_fetch_fw(path, name, sha256) - helpers.fetch_fw = fetch_fw - except ImportError: - pass -_patch_tinygrad_fetch_fw() +patch_fetch_fw() MODEL_TYPES = ('vision_policy', 'supercombo', 'vision_multi_policy') WARP_INPUTS = ['tfm', 'big_tfm'] diff --git a/openpilot/sunnypilot/modeld_v2/compile_warp.py b/openpilot/sunnypilot/modeld_v2/compile_warp.py new file mode 100755 index 0000000000..d7ee412d04 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/compile_warp.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import argparse +import numpy as np + +from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, write_bundle +from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit + + +def main(): + parser = argparse.ArgumentParser(description="Compile the two-frame yuv420 camera warp into a plain tinygrad artifact") + parser.add_argument("--frame", required=True, help="width,height,stride,y_height,uv_height,buffer_size") + parser.add_argument("--warp-to", required=True, help="model input WxH") + parser.add_argument("--output", required=True) + args = parser.parse_args() + + from examples.openpilot.compile_warp import make_frame_prepare, parse_frame, parse_size + from tinygrad import Device, Tensor, TinyJit + frame = parse_frame(args.frame) + warp = make_frame_prepare(frame, *parse_size(args.warp_to)) + run = TinyJit(lambda input_frame, M_inv: Tensor.stack(*(warp(input_frame[i], M_inv[i]) for i in range(2))), prune=True) + rng = np.random.default_rng(42) + inputs = {"input_frame": Tensor(rng.integers(0, 256, (2, frame.size), dtype=np.uint8), device=Device.DEFAULT).realize(), + "M_inv": Tensor((rng.standard_normal((2, 3, 3)) * 8).astype(np.float32), device=Device.DEFAULT).realize()} + with record_kernel_linear() as recorded: + run(**inputs) + run(**inputs) + check, _ = capture_frame(run, inputs) + assert len(recorded) == 1, f"expected one captured kernel list, got {len(recorded)}" + part = serialize_jit(run, recorded[0], check) + PlainJit(*part) + specs = {"input_frame": ((2, frame.size), "|u1", Device.DEFAULT), "M_inv": ((2, 3, 3), " tuple[int, int | None]: + from tinygrad.uop.ops import Ops + start, size = 0, None + while u.op in (Ops.AFTER, Ops.BITCAST, Ops.SHRINK): + if u.op is Ops.SHRINK: + start, size = start + u.src[1].arg, u.src[2].arg if size is None else size + u = u.src[0] + return start, size + + +def _address(v): + from tinygrad.uop.ops import Ops + add = 0 + while v.op is not Ops.GETADDR: + add += v.src[1].arg if v.op is Ops.ADD else 0 + v = v.src[0] + start, size = _view(v.src[0]) + return _root(v.src[0]), add + start, size + + +def _patched(buf, topo) -> tuple[bytearray, dict]: + from tinygrad.uop.ops import Ops + blob, rows = bytearray(buf.max_numel() * buf.dtype.itemsize), {} + for u in topo: + if u.op is not Ops.STORE or _root(u.src[0]) is not buf: + continue + if (value := u.src[1]).op is Ops.BINARY or value.src[0].op is Ops.BINARY: + data = (value if value.op is Ops.BINARY else value.src[0]).arg + blob[:len(data)] = data + else: + view, offsets = u.src[0].src + rows |= {_view(view)[0] + o.arg * view.dtype.itemsize: w for o, w in zip(offsets.src, value.src, strict=True)} + return blob, rows + + +def _evaluate(u, env): + from tinygrad.uop.ops import GroupOp, Ops, exec_alu + if u.op in (Ops.AFTER, Ops.CAST, Ops.BITCAST): + return _evaluate(u.src[0], env) + if u.op is Ops.CONST: + return u.arg + if u.op in (Ops.PARAM, Ops.RANGE): + return env.get(u.arg.slot if u.op is Ops.PARAM else u) + if u.op is Ops.LOAD: + table, index = _root(u.src[0].src[0]), _evaluate(u.src[0].src[1], env) + return struct.unpack_from(u.dtype.fmt, table.arg, index * u.dtype.itemsize)[0] if table.op is Ops.BINARY and index is not None else None + if u.op in GroupOp.ALU and None not in (values := [_evaluate(x, env) for x in u.src]): + return exec_alu(u.op, u.dtype, values, False) + return None + + +def _input_pokes(submit, kernargs) -> dict[int, tuple[int, int]]: + from tinygrad.uop.ops import Ops + outer = dict(enumerate(submit.src[1:])) + kernargs_params = {i for i, a in outer.items() if a.op is Ops.GETADDR and _root(a.src[0]) is kernargs} + inputs = {i: _root(a.src[0]).arg.slot for i, a in outer.items() if a.op is Ops.GETADDR and _root(a.src[0]).op is Ops.PARAM} + pokes = {} + for call in (u for u in submit.src[0].src[0].toposort() if u.op is Ops.CALL and len(u.src) > 3): + _, address, value, *_ = call.src[1:] + used = list({x.arg.slot for x in value.toposort() if x.op is Ops.PARAM} & inputs.keys()) + if len(used) != 1 or not kernargs_params & {x.arg.slot for x in address.toposort() if x.op is Ops.PARAM}: + continue + ranges = list(dict.fromkeys(x for x in (*address.toposort(), *value.toposort()) if x.op is Ops.RANGE)) + for trip in itertools.product(*[range(int(r.vmax) + 1) for r in ranges]): + env = dict.fromkeys(outer, 0) | dict(zip(ranges, trip, strict=True)) + if (offset := _evaluate(address, env)) % 8 == 0: + pokes[offset] = (inputs[used[0]], _evaluate(value, env)) + return pokes + + +def _elf(image: bytes, rodata: int) -> bytes: + names, offset, data, headers = b"\0.text\0.rodata\0.shstrtab\0", 64, b"", [] + for name, kind, address, content in [(0, 0, 0, b""), (1, 1, ALIGNMENT, image[:rodata]), (7, 1, ALIGNMENT + rodata, image[rodata:]), (15, 3, 0, names)]: + headers.append(struct.pack(" tuple[dict, memoryview]: + import tinygrad.runtime.autogen.am.pm4_nv as pm4 + import tinygrad.runtime.autogen.amd_gpu as amd_gpu + from tinygrad.dtype import dtypes + from tinygrad.engine.jit import _prepare_jit_inputs + from tinygrad.helpers import unwrap + from tinygrad.uop.ops import Ops + submit = jit.captured._linear.src[0].without_after + kernels, topo = submit.arg.aux.kernels, list(jit.captured._linear.toposort(enter_calls=True)) + allocs = {str(u.tag): u for u in topo if u.op is Ops.ALLOC and u.tag is not None} + (commands, patched), kernargs = _patched(allocs["cmdbuf_compute_0"], topo), allocs["kernargs_compute_0"] + argument_words, pokes = _patched(kernargs, topo)[1], _input_pokes(submit, kernargs) + sh = {name: getattr(amd_gpu, f"reg{name}") + amd_gpu.GC_BASE__INST0_SEG0 - pm4.PACKET3_SET_SH_REG_START + for name in ("COMPUTE_PGM_LO", "COMPUTE_USER_DATA_0", "COMPUTE_NUM_THREAD_X")} if hasattr(amd_gpu, "regCOMPUTE_PGM_LO") else \ + {"COMPUTE_PGM_LO": 0x20c, "COMPUTE_USER_DATA_0": 0x240, "COMPUTE_NUM_THREAD_X": 0x207} + + dispatches, registers, offset = [], {}, 0 + while offset < len(commands): + word = lambda o: patched.get(o, struct.unpack_from("> 30 != 3: + offset += 4 + continue + count, opcode = ((header >> 16) & 0x3fff) + 1, (header >> 8) & 0xff + body = [word(offset + 4 + 4 * i) for i in range(count)] + if opcode == pm4.PACKET3_SET_SH_REG: + registers |= {body[0] + i: v for i, v in enumerate(body[1:])} + elif opcode == pm4.PACKET3_DISPATCH_DIRECT: + local = [registers[sh["COMPUTE_NUM_THREAD_X"] + i] for i in range(3)] + dispatches.append((registers[sh["COMPUTE_PGM_LO"]], registers[sh["COMPUTE_USER_DATA_0"]], local, body[:3])) + offset += 4 + 4 * count + assert len(dispatches) == len(kernels), f"decoded {len(dispatches)} dispatches, the JIT has {len(kernels)} kernels" + + sink = tempfile.TemporaryFile(dir=".") + blob, buffers, programs = Blob(sink), {}, {} + u8 = repr(dtypes.uint8) + + def store(array) -> dict: + array = np.ascontiguousarray(array) + return {"data": blob.add(array.tobytes()), "dtype": array.dtype.str, "shape": list(array.shape)} + + calls, written, needs_init = [], set(), set() + for (program, arguments, local, grid), kernel in zip(dispatches, kernels, strict=True): + image_alloc, entry, _ = _address(program) + if image_alloc not in programs: + image = bytes(_patched(image_alloc, topo)[0]) + rodata = next(r for r in range(0, len(image) - 24, 64) if r + struct.unpack_from(" tuple[dict[str, int], int]: + offsets, offset = {}, 0 + for name, shape in ({"tfm": (2, 3, 3)} | shapes).items(): + offsets[name], offset = offset, offset + -(-math.prod(shape) * 4 // 128) * 128 + return offsets | {"frames": offset}, offset + 2 * frame_size + + +class _Blobs: # the model's and the warp's blobs as one address space, for a part whose ranges span both files + def __init__(self, *blobs): + self.blobs, self.starts = blobs, list(itertools.accumulate([0] + [len(blob) for blob in blobs[:-1]])) + + def __getitem__(self, span: slice): + index = bisect.bisect_right(self.starts, span.start) - 1 + return self.blobs[index][span.start - self.starts[index]:span.stop - self.starts[index]] + + +def fused_with_warp(cam_w: int, cam_h: int, chestnut: bool): # load_model's extend: a native model runs upload, warp and model as one submit + return partial(frame_part, f"{BASEDIR}/openpilot/sunnypilot/modeld_v2/models/{'big_' if chestnut else ''}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl") + + +def frame_part(warp_path: str, parts: dict, extras: dict) -> dict: + if "input_specs" not in extras: # a legacy model warps in its own parts + return {} + (model, model_blob), (warp, warp_blob) = parts["run"], read_parts(warp_path)[0]["run"] + states = sorted(name for name in extras["input_specs"] if f"next_{name}" in extras["metadata"]["output_shapes"]) + host = {name: shape for name, (shape, _, _) in extras["input_specs"].items() if name not in states and name != "new_img"} + frame_size = next(s["numel"] for s in warp["inputs"] if s["name"] == "input_frame") // 2 + offsets, packed = pack_layout(host, frame_size) + shift, upload, u8 = len(model_blob), len(model["buffers"]) + len(warp["buffers"]), repr(_dtype("dtypes.uint8")) + + def view(name: str, nbytes: int, dtype: str) -> dict: + return {"buffer": upload, "offset": offsets[name], "nbytes": nbytes, "dtype": dtype} + outputs = sorted(s["slot"] for s in model["inputs"] if s["name"] is None) + names = sorted(["host", *states]) + slots = {s["slot"]: names.index(s["name"]) for s in model["inputs"] if s["name"] in states} | \ + {slot: len(names) + i for i, slot in enumerate(outputs)} + returned = warp["returns"]["tensors"][0] + model_inputs = {s["slot"]: {"input": slots[s["slot"]]} if s["slot"] in slots else + {k: v for k, v in returned.items() if k != "shape"} | {"buffer": returned["buffer"] + len(model["buffers"])} if s["name"] == "new_img" else + view(s["name"], s["numel"] * _dtype(s["dtype"]).itemsize, s["dtype"]) for s in model["inputs"]} + warp_inputs = {s["slot"]: view("frames" if s["name"] == "input_frame" else "tfm", s["numel"] * _dtype(s["dtype"]).itemsize, s["dtype"]) + for s in warp["inputs"]} + + def remap(calls: list, inputs: dict, buffers: int, lib: int) -> list: + return [call | ({"lib": [call["lib"][0] + lib, call["lib"][1] + lib]} if "lib" in call else {}) | + {"args": [dict(inputs[a["input"]]) if "input" in a else a | {"buffer": a["buffer"] + buffers} for a in call["args"]]} for call in calls] + header = {"input_names": names, "returns": {"kind": "NoneType", "tensors": []}, "check": None, + "inputs": [{"slot": names.index("host"), "name": "host", "dtype": u8, "numel": packed, "device": "NPY"}] + + [s | {"slot": slots[s["slot"]]} for s in model["inputs"] if s["slot"] in slots], + "buffers": model["buffers"] + [b | ({"init": [b["init"][0] + shift, b["init"][1] + shift]} if "init" in b else {}) for b in warp["buffers"]] + + [{"nbytes": packed, "device": model["inputs"][0]["device"], "dtype": u8}], + "calls": [{"kind": "copy", "args": [{"buffer": upload, "offset": 0, "nbytes": packed, "dtype": u8}, {"input": names.index("host")}]}] + + remap(warp["calls"], warp_inputs, len(model["buffers"]), shift) + remap(model["calls"], model_inputs, 0, 0)} + return {"run": (header, _Blobs(model_blob, warp_blob))} # replaces the model's run: one set of buffers, no separate warp diff --git a/openpilot/sunnypilot/modeld_v2/helpers.py b/openpilot/sunnypilot/modeld_v2/helpers.py index 8d0fc0e7fd..f45584a611 100644 --- a/openpilot/sunnypilot/modeld_v2/helpers.py +++ b/openpilot/sunnypilot/modeld_v2/helpers.py @@ -5,12 +5,14 @@ This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ +import bisect import builtins import contextlib import dataclasses import functools import hashlib import io +import itertools import json import math import mmap @@ -42,6 +44,14 @@ def _patch_system_flock_acquire(): _patch_system_flock_acquire() +def patch_fetch_fw() -> None: + try: + import zstandard + except ImportError: + return + zstandard.decompress = lambda data, *_: zstandard.ZstdDecompressor().stream_reader(data).read() # ty: ignore[invalid-assignment] + + class ArtifactMismatch(RuntimeError): pass @@ -92,7 +102,7 @@ def write_bundle(path: str, parts: dict, extras: dict | None = None): def read_parts(path: str) -> tuple[dict, dict]: with open(path, "rb") as file: - data = memoryview(mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_READ)) + data = memoryview(mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_COPY)) if bytes(data[:len(MAGIC)]) != MAGIC: raise ValueError(f"{path} is not a plain tinygrad model, recompile it with compile_modeld.py") start = len(MAGIC) + 8 + struct.unpack_from(" tuple[dict, dict]: return parts, _MetadataUnpickler(io.BytesIO(bytes(blob[slice(*header["extras"])]))).load() -def load_model(path: str, device_map: dict | None = None, verify: bool = True, cache: bool = True) -> dict: +def _extended(path: str, extend) -> tuple[dict, dict, str]: parts, extras = read_parts(path) - if cache and (cached := _read_cache(path, parts, device_map)) is not None: + added = extend(parts, extras) if extend else {} + return parts | added, extras, _parts_digest(added) if added else "" + + +def load_model(path: str, device_map: dict | None = None, verify: bool = True, cache: bool = True, extend=None) -> dict: + parts, extras, extra = _extended(path, extend) + if cache and (cached := _read_cache(path, parts, device_map, extra)) is not None: return extras | cached shared: dict = {} jits = {key: PlainJit(header, blob, device_map, verify, shared) for key, (header, blob) in parts.items()} if cache: - _write_cache(path, jits, device_map) + _write_cache(path, jits, device_map, extra) return extras | jits @@ -118,19 +134,30 @@ def tinygrad_identity() -> str: root, digest = os.path.dirname(tinygrad.__file__), hashlib.sha256() for directory, _, files in sorted(os.walk(root)): for name in sorted(file for file in files if file.endswith(".py")): - stat = os.stat(path := os.path.join(directory, name)) - digest.update(f"{os.path.relpath(path, root)}:{stat.st_size}:{stat.st_mtime_ns};".encode()) + with open(path := os.path.join(directory, name), "rb") as file: + digest.update(f"{os.path.relpath(path, root)}:".encode() + file.read()) return digest.hexdigest() -def _cache_key(path: str, device_map: dict | None) -> bytes: +def _parts_digest(parts: dict) -> str: + digest = hashlib.sha256() + for _, (header, blob) in sorted(parts.items(), key=lambda item: repr(item[0])): + digest.update(json.dumps(header, sort_keys=True).encode()) + for call in header["calls"]: + if call["kind"] == "program": + digest.update(blob[slice(*call["lib"])]) + return digest.hexdigest() + + +def _cache_key(path: str, device_map: dict | None, extra: str = "") -> bytes: stat = os.stat(path) return json.dumps({"format": MAGIC.decode(), "tinygrad": tinygrad_identity(), "size": stat.st_size, "mtime_ns": stat.st_mtime_ns, - "device_map": sorted((device_map or {}).items()), "env": [os.environ.get(name, "") for name in RUNTIME_ENVIRONMENT]}).encode() + "extra": extra, "device_map": sorted((device_map or {}).items()), + "env": [os.environ.get(name, "") for name in RUNTIME_ENVIRONMENT]}).encode() -def _cache_header_matches(file, path: str, device_map: dict | None) -> bool: - return file.read(struct.unpack(" bool: + return file.read(struct.unpack(" bool: # entry point for warmup proc @@ -141,10 +168,10 @@ def cache_is_fresh(path: str, device_map: dict | None = None) -> bool: # entry p return False -def _read_cache(path: str, parts: dict, device_map: dict | None) -> dict | None: +def _read_cache(path: str, parts: dict, device_map: dict | None, extra: str = "") -> dict | None: try: with open(f"{path}.cache", "rb") as file: - if not _cache_header_matches(file, path, device_map): + if not _cache_header_matches(file, path, device_map, extra): return None shared: dict = {} buffers = {repr(key): _allocate_buffers(header, blob, device_map, shared) for key, (header, blob) in parts.items()} @@ -153,7 +180,7 @@ def _read_cache(path: str, parts: dict, device_map: dict | None) -> dict | None: return None -def _write_cache(path: str, jits: dict, device_map: dict | None) -> None: +def _write_cache(path: str, jits: dict, device_map: dict | None, extra: str = "") -> None: from tinygrad.device import Buffer owners = {id(buffer): (repr(key), index) for key, jit in jits.items() for index, buffer in enumerate(jit.buffers)} @@ -161,7 +188,7 @@ def _write_cache(path: str, jits: dict, device_map: dict | None) -> None: def persistent_id(self, obj): return owners.get(id(obj)) if isinstance(obj, Buffer) else None - key, tmp_path = _cache_key(path, device_map), f"{path}.cache.{os.getpid()}.tmp" + key, tmp_path = _cache_key(path, device_map, extra), f"{path}.cache.{os.getpid()}.tmp" with contextlib.suppress(Exception): with open(tmp_path, "wb") as file: file.write(struct.pack(" str: return Device.canonicalize(device_map.get(recorded, device_map.get(recorded.split(":", 1)[0], recorded))) +def _upload(buffer, data: memoryview, chunk: int = 16 << 20) -> None: + from tinygrad.device import Buffer + source, chunk = Buffer("NPY", len(data), opaque=memoryview(bytearray(data)) if data.readonly else data), min(chunk, len(data)) + for offset in sorted({*range(0, len(data) - chunk, chunk), len(data) - chunk}): + buffer.view(chunk, offset).ensure_allocated().copy_from(source.view(chunk, offset).ensure_allocated()) + + def _allocate_buffers(header: dict, blob: memoryview, device_map: dict | None, shared: dict) -> list: import numpy as np from tinygrad.device import Buffer written = {call["args"][i]["buffer"] for call in header["calls"] for i in (call["outs"] if call["kind"] == "program" else (0,)) if "buffer" in call["args"][i]} + entries = [(_device(entry["device"], device_map), blob[slice(*entry["init"])] if "init" in entry else None, entry) for entry in header["buffers"]] + keys = [(device, entry["dtype"], np.frombuffer(data, np.uint8).ctypes.data, len(data)) if data and index not in written else object() + for index, (device, data, entry) in enumerate(entries)] + for device in {key[0] for key in keys if isinstance(key, tuple) and key not in shared}: + if shared.setdefault(("mapped", device), Buffer(device, 1).ensure_allocated().get_storage().host is not None): + continue + spans = [(entry["init"], entry["nbytes"], key) for (_, _, entry), key in zip(entries, keys, strict=True) + if isinstance(key, tuple) and key[0] == device and key not in shared] + files = getattr(blob, "starts", [0]) # a part read from several files (frame_fusion's) is uploaded a block per file + for _, group in itertools.groupby(sorted(spans, key=lambda span: span[0][0]), key=lambda span: bisect.bisect_right(files, span[0][0])): + group = list(group) + start, end = min(span[0] for span, _, _ in group), max(span[1] for span, _, _ in group) + _upload(block := Buffer(device, end - start).ensure_allocated(), blob[start:end]) + shared |= {key: block.view(nbytes, span[0] - start).ensure_allocated() for span, nbytes, key in group} buffers = [] - for index, entry in enumerate(header["buffers"]): - device, data = _device(entry["device"], device_map), blob[slice(*entry["init"])] if "init" in entry else None - # read-only data the parts share in the file (weights common to every camera resolution) is uploaded once to the nut - key = (device, entry["dtype"], np.frombuffer(data, np.uint8).ctypes.data, len(data)) if data and index not in written else object() + for (device, data, entry), key in zip(entries, keys, strict=True): if key not in shared: shared[key] = Buffer(device, entry["nbytes"], initial_value=pickle.PickleBuffer(data) if data else None).ensure_allocated() buffers.append(shared[key]) diff --git a/openpilot/sunnypilot/modeld_v2/model_adapters.py b/openpilot/sunnypilot/modeld_v2/model_adapters.py index e5f045d0ee..5d1e68feef 100644 --- a/openpilot/sunnypilot/modeld_v2/model_adapters.py +++ b/openpilot/sunnypilot/modeld_v2/model_adapters.py @@ -11,13 +11,14 @@ import pickle import numpy as np from openpilot.common.basedir import BASEDIR +from openpilot.sunnypilot.modeld_v2.frame_fusion import pack_layout +from openpilot.sunnypilot.modeld_v2.helpers import load_model from openpilot.sunnypilot.modeld_v2.compile_modeld import (POLICY_INPUTS, derive_frame_skip, make_split_input_queues, make_supercombo_input_queues) from openpilot.sunnypilot.modeld_v2.stock_dependencies import make_input_queues as stock_make_input_queues, nv12_copy_size from openpilot.system.camerad.cameras.nv12_info import get_nv12_info from tinygrad.device import Buffer -from tinygrad.dtype import DType, dtypes -from tinygrad.helpers import round_up +from tinygrad.dtype import DType from tinygrad.tensor import Tensor from tinygrad.uop.ops import UOp @@ -64,16 +65,10 @@ class BaseModelAdapter: self.warp_frame_size = self.nv12_info[3] return self.jits[(self.cam_w, self.cam_h)] - warp_dir = Path(BASEDIR) / "openpilot/sunnypilot/modeld_v2/models" warp_name = f'{"big_" if self.chestnut else ""}driving_warp_{self.cam_w}x{self.cam_h}_tinygrad.pkl' - with open(warp_dir / warp_name, 'rb') as f: - warp_data = pickle.load(f) - run_warp = warp_data['run'] - if 'input_specs' in warp_data and 'input_frame' in warp_data['input_specs']: - self.warp_frame_size = warp_data['input_specs']['input_frame'][0][1] - else: - self.warp_frame_size = self.nv12_info[3] if self.chestnut else self.frame_copy_size - return run_warp + warp = load_model(str(Path(BASEDIR) / "openpilot/sunnypilot/modeld_v2/models" / warp_name), cache=False) + self.warp_frame_size = warp['input_specs']['input_frame'][0][1] + return warp['run'] class LegacyModelAdapter(BaseModelAdapter): @@ -171,27 +166,19 @@ class NativeTinygradAdapter(BaseModelAdapter): self._vision_input_names = ['img', 'big_img'] self.vision_output_slices = pickle.loads(codecs.decode(self.jits['metadata']['metadata']['output_slices'].encode(), 'base64')) - self.run_warp = self._load_warp() + self.warp_frame_size = self.frame_copy_size self._init_common() self.run_model = self.jits['run'] - warp_frame_size = getattr(self, 'warp_frame_size', self.nv12_info[3]) self.input_queues = {name: Tensor(np.zeros(shape, dtype=dtype), device=self.model_device).realize() for name, (shape, dtype) in self.input_shapes.items() if name in self.state_pairs} - shapes = {'tfm': (2, 3, 3)} | {name: shape for name, (shape, _) in self.input_shapes.items() - if name not in self.state_pairs and name != 'new_img'} - npy_size = sum(round_up(math.prod(shape) * 4, 128) for shape in shapes.values()) - self.packed_input = np.zeros(npy_size + 2 * warp_frame_size, dtype=np.uint8) - self.input_host = Tensor(self.packed_input, device='NPY')._buffer() - self.input_device = Tensor(self.packed_input, device=self.model_device)._buffer() - self.numpy_inputs = {} - offset = 0 - for name, shape in shapes.items(): - self.numpy_inputs[name] = np.ndarray(shape, dtype=np.float32, buffer=self.packed_input, offset=offset) - self.input_queues[name] = input_view(self.input_device, shape, dtypes.float32, offset) - offset += round_up(self.numpy_inputs[name].nbytes, 128) - self.frames = self.packed_input[npy_size:].reshape(2, warp_frame_size) - self.warp_inputs = {'input_frame': input_view(self.input_device, self.frames.shape, dtypes.uint8, npy_size), 'M_inv': self.input_queues.pop('tfm')} + shapes = {name: shape for name, (shape, _) in self.input_shapes.items() if name not in self.state_pairs and name != 'new_img'} + offsets, packed = pack_layout(shapes, self.warp_frame_size) + self.packed_input = np.zeros(packed, dtype=np.uint8) + self.host = Tensor(self.packed_input, device='NPY') + self.numpy_inputs = {name: np.ndarray(shape, dtype=np.float32, buffer=self.packed_input, offset=offsets[name]) + for name, shape in ({'tfm': (2, 3, 3)} | shapes).items()} + self.frames = self.packed_input[offsets['frames']:].reshape(2, self.warp_frame_size) self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in self.jits['output_specs'].items()} for name, next_name in self.state_pairs.items(): @@ -210,9 +197,7 @@ class NativeTinygradAdapter(BaseModelAdapter): self.input_queues[key].assign(0).realize() def run(self): - self.input_device.copy_from(self.input_host) - self.input_queues['new_img'] = self.run_warp(**self.warp_inputs) - self.run_model(output_buffers=self.outputs, **self.input_queues) + self.run_model(host=self.host, **self.input_queues, output_buffers=self.outputs) return self.outputs['outputs'] diff --git a/openpilot/sunnypilot/modeld_v2/modeld.py b/openpilot/sunnypilot/modeld_v2/modeld.py index b669868d6f..dda0f834a4 100755 --- a/openpilot/sunnypilot/modeld_v2/modeld.py +++ b/openpilot/sunnypilot/modeld_v2/modeld.py @@ -7,8 +7,10 @@ See the LICENSE.md file in the root directory for more details. """ from collections.abc import Callable +import gc import os os.environ['GMMU'] = '0' +from tinygrad.device import Device import numpy as np import threading import time @@ -16,7 +18,7 @@ from setproctitle import setproctitle import openpilot.cereal.messaging as messaging from openpilot.common.hardware import COMMA_HARDWARE -from openpilot.selfdrive.modeld.helpers import chestnut_present +from openpilot.selfdrive.modeld.helpers import chestnut_present, wait_for_chestnut from openpilot.cereal import log from opendbc.car.structs import car from openpilot.cereal.services import SERVICE_LIST @@ -26,6 +28,7 @@ from msgq.visionipc import VisionIpcClient, VisionBuf from opendbc.car.car_helpers import get_demo_car_params from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params +from openpilot.common.hardware.usb import cable_connected from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.realtime import config_realtime_process, DT_MDL from openpilot.common.transformations.camera import DEVICE_CAMERAS @@ -42,6 +45,7 @@ from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelp from openpilot.sunnypilot.livedelay.helpers import get_lat_delay from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase from openpilot.sunnypilot.modeld_v2.helpers import load_model +from openpilot.sunnypilot.modeld_v2.frame_fusion import fused_with_warp from openpilot.sunnypilot.modeld_v2.model_adapters import get_model_adapter from openpilot.sunnypilot.models.helpers import get_active_bundle from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController @@ -106,7 +110,7 @@ class ModelState(ModelStateBase): def _init_combined(self, pkl_path, cam_w, cam_h, bundle): cloudlog.warning(f"loading combined pkl: {pkl_path}") - jits = load_model(pkl_path) + jits = load_model(pkl_path, extend=fused_with_warp(cam_w, cam_h, self.chestnut)) metadata = jits['metadata'] self.WARP_DEV = metadata.get('warp_dev', 'QCOM') if COMMA_HARDWARE else 'CPU' @@ -178,6 +182,8 @@ class ModelState(ModelStateBase): self.numpy_inputs['big_tfm'][:, :] = transforms[self._wide_key].reshape(3, 3) raw_outputs = self.adapter.run() + if self.chestnut and (err := Device[self.DEV].error_state.host.view(fmt='q')[0]): + raise RuntimeError(f"chestnut failed with {err}") if after_enqueue is not None: after_enqueue() @@ -252,9 +258,8 @@ def main(demo=False): cloudlog.bind(daemon=PROCESS_NAME) setproctitle(PROCESS_NAME) - config_realtime_process(7, 54) - CHESTNUT = chestnut_present() + CHESTNUT = _find_driving_pkl(get_active_bundle(chestnut=True)) is not None and (chestnut_present() or cable_connected()) if CHESTNUT: from tinygrad.runtime.ops_amd import AMDDevice AMDDevice.wait_timeout_ms = 3000 @@ -263,6 +268,8 @@ def main(demo=False): params.put_bool("ChestnutLoading", CHESTNUT) params.remove("ChestnutActive") + gc.disable() + # visionipc clients while True: available_streams = VisionIpcClient.available_streams("camerad", block=False) @@ -295,6 +302,7 @@ def main(demo=False): def load_big(): nonlocal big_model try: + wait_for_chestnut() m = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, chestnut=True) m.warmup() big_model = m @@ -304,7 +312,13 @@ def main(demo=False): loader.start() loader.join(BIG_MODEL_TIMEOUT) model = big_model - params.put_bool("ChestnutActive", model is not None) + if model is None and not params.get_bool("ChestnutRetry"): + # a crank can cut the chestnut power during the load, retry once with an onroad cycle + cloudlog.error("big model load failed, cycling onroad to retry") + params.put_bool("ChestnutRetry", True) + params.put_bool("OnroadCycleRequested", True) + else: + params.put_bool("ChestnutActive", model is not None) small_model = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, chestnut=False) if model is None or CHESTNUT else None if model is None: @@ -313,10 +327,13 @@ def main(demo=False): assert model is not None cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") + config_realtime_process(7, 54) + # messaging pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutGpuState"] if CHESTNUT else []) pm = PubMaster(pub_socks) - sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) + sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", + "carControl", "controlsState", "lateralDelay"]) publish_state = PublishState() chestnut_state = ChestnutGpuState(pm, model.chestnut) if CHESTNUT else None @@ -444,7 +461,7 @@ def main(demo=False): run_count % round(model.constants.MODEL_FREQ / SERVICE_LIST['chestnutGpuState'].frequency) == 0) model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None) except Exception: - if not params.get_bool("ChestnutActive"): + if not model.chestnut: raise cloudlog.exception("big model failed, fall back to small") params.put_bool("ChestnutActive", False) @@ -463,6 +480,8 @@ def main(demo=False): posenet_send = messaging.new_message('cameraOdometry') mdv2sp_send = messaging.new_message('modelDataV2SP') + if not sm['carControl'].latActive: + prev_action.desiredCurvature = sm['controlsState'].curvature action = model.get_action_from_model(model_output, prev_action, lat_action_t, long_action_t, v_ego) prev_action = action fill_model_msg(drivingdata_send, modelv2_send, model_output, action, diff --git a/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1344x760_tinygrad.pkl b/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1344x760_tinygrad.pkl index d942c7ca8d..39c5f9b2ba 100644 --- a/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1344x760_tinygrad.pkl +++ b/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1344x760_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ebac006f5e35ddb9c7da8ab38f9af87d6d0a87a3299b07cd118f88581f0f561f -size 872573 +oid sha256:f1689356ff8e516f87e3d75f6b53e66953a11ee5a97e859b4808c9a16bfe671b +size 3677806 diff --git a/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1928x1208_tinygrad.pkl b/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1928x1208_tinygrad.pkl index fdf52b15d8..cef1d05c92 100644 --- a/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1928x1208_tinygrad.pkl +++ b/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1928x1208_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d8eab79e53ebe1bbc3c633acc2a6bee80612cfe7fb5246afff6e0b7259c3a0d3 -size 872317 +oid sha256:56cc9690e0e9fb719323d2c6646c1305e8bc6bbdfdc807724a605a0d0a37554b +size 7904366 diff --git a/openpilot/sunnypilot/modeld_v2/models/driving_warp_1344x760_tinygrad.pkl b/openpilot/sunnypilot/modeld_v2/models/driving_warp_1344x760_tinygrad.pkl index 7f59021ab0..cdda8e8f69 100644 --- a/openpilot/sunnypilot/modeld_v2/models/driving_warp_1344x760_tinygrad.pkl +++ b/openpilot/sunnypilot/modeld_v2/models/driving_warp_1344x760_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c48c9150afd01bd7071641415a248308c9a7b3cb47e7095862ef66c9f0429e43 -size 838183 +oid sha256:4b22fd6bcb3a3e8a642e6866449dd9c30c12e694ff0741cad9e34e4b00d8c5fa +size 3698799 diff --git a/openpilot/sunnypilot/modeld_v2/models/driving_warp_1928x1208_tinygrad.pkl b/openpilot/sunnypilot/modeld_v2/models/driving_warp_1928x1208_tinygrad.pkl index b7c5251b48..565952f2ff 100644 --- a/openpilot/sunnypilot/modeld_v2/models/driving_warp_1928x1208_tinygrad.pkl +++ b/openpilot/sunnypilot/modeld_v2/models/driving_warp_1928x1208_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:59564a7d214fe13dbb30cdd068437c4bf35ec071789c922288046636f711cdfa -size 837991 +oid sha256:9a17fd6acece4fab23784ef0f3f8bba28a049e9599e5e9e5ee7b36e4b1d24a6f +size 7924847 diff --git a/openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py b/openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py index 05001cd670..01dd292e29 100644 --- a/openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py +++ b/openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py @@ -67,7 +67,7 @@ def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict, "global_size": [int(x) for x in global_size], "local_size": [int(x) for x in local_size], "globals": list(info.globals), "outs": list(info.outs), "ins": list(info.ins), "args": arguments}) outs, ins = info.outs, info.ins - elif ast.op is Ops.COPY: + elif ast.op is Ops.STORE: # a copy: STORE(PARAM dst, PARAM src) calls.append({"kind": "copy", "args": arguments}) outs, ins = (0,), (1,) else: diff --git a/openpilot/sunnypilot/modeld_v2/tests/helpers.py b/openpilot/sunnypilot/modeld_v2/tests/helpers.py index 9ab6af7afa..82a6180039 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/helpers.py +++ b/openpilot/sunnypilot/modeld_v2/tests/helpers.py @@ -209,7 +209,7 @@ def patch_modeld(monkeypatch): def _patch(bundle): monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None, *, chestnut=None: bundle) monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, chestnut=None: bundle) - monkeypatch.setattr(modeld_module, 'load_model', lambda path: pickle.loads(pathlib.Path(path).read_bytes())) + monkeypatch.setattr(modeld_module, 'load_model', lambda path, **_: pickle.loads(pathlib.Path(path).read_bytes())) return _patch diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py b/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py index 7967bf2f97..9d431c1803 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py @@ -42,7 +42,7 @@ class TestFallback(OpenpilotTestCase): small_bundle = ModelParser.parse_models(small_json)[-1] oob_bytes = pickle.dumps(tests_helpers.make_pkl_data(tests_helpers.ARCHETYPES['supercombo_non20hz'])) - monkeypatch.setattr(modeld_module, 'load_model', lambda path: pickle.loads(open(path, 'rb').read())) + monkeypatch.setattr(modeld_module, 'load_model', lambda path, **_: pickle.loads(open(path, 'rb').read())) for bundle in (big_bundle, small_bundle): (tmp_path / bundle.models[0].artifact.fileName).write_bytes(oob_bytes) diff --git a/openpilot/sunnypilot/modeld_v2/warmup.py b/openpilot/sunnypilot/modeld_v2/warmup.py index c4e12b83c1..852f2bc4fc 100755 --- a/openpilot/sunnypilot/modeld_v2/warmup.py +++ b/openpilot/sunnypilot/modeld_v2/warmup.py @@ -13,28 +13,28 @@ import time from openpilot.common.hardware.hw import Paths from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog -from openpilot.selfdrive.modeld.helpers import chestnut_present -from openpilot.sunnypilot.modeld_v2.helpers import cache_is_fresh, load_model +from openpilot.sunnypilot.modeld_v2.helpers import cache_is_fresh, load_model, read_parts from openpilot.sunnypilot.models.helpers import get_selected_bundle def main(): """ The main entry point for offroad cache creation. - Runs every 10 seconds and checks for stale model caches. - Chestnut cache needs the gpu to build, so that slot waits until it is plugged in, or simply runs during modeld startup. + Runs every 10 seconds and checks for a stale qcom model cache. """ - params, failed, spawn = Params(), set(), multiprocessing.get_context("spawn") + params, failed, native, spawn = Params(), set(), set(), multiprocessing.get_context("spawn") while True: - for bundle in [get_selected_bundle(params, source) for source in (("qcom", "chestnut") if chestnut_present() else ("qcom",))]: - path = os.path.join(Paths.model_root(), bundle.models[0].artifact.fileName) if bundle and bundle.models else "" - if not os.path.isfile(path) or cache_is_fresh(path) or (attempt := (path, os.stat(path).st_mtime_ns)) in failed: - continue - (worker := spawn.Process(target=load_model, args=(path,), daemon=True)).start() - worker.join() - if not cache_is_fresh(path): - cloudlog.error(f"model cache warmup failed for {path} with exit code {worker.exitcode}") - failed.add(attempt) + bundle = get_selected_bundle(params, "qcom") + path = os.path.join(Paths.model_root(), bundle.models[0].artifact.fileName) if bundle and bundle.models else "" + if os.path.isfile(path) and not cache_is_fresh(path) and (attempt := (path, os.stat(path).st_mtime_ns)) not in failed | native: + if "input_specs" in read_parts(path)[1]: # native models are fused + native.add(attempt) + else: + (worker := spawn.Process(target=load_model, args=(path,), daemon=True)).start() + worker.join() + if not cache_is_fresh(path): + cloudlog.error(f"model cache warmup failed for {path} with exit code {worker.exitcode}") + failed.add(attempt) time.sleep(10) From b6170ac5e91f2b019df0cfa459a1a37f9c4a13a7 Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Sat, 17 Oct 2026 21:43:48 -0400 Subject: [PATCH 259/270] mapd: disable map-based Smart Cruise Control and speed limit sources until mapd has a position source --- .../ui/sunnypilot/layouts/settings/cruise.py | 7 +++- .../cruise_sub_layouts/speed_limit_policy.py | 6 +++ openpilot/sunnypilot/mapd/__init__.py | 4 ++ .../smart_cruise_control/map_controller.py | 5 ++- .../tests/test_map_controller.py | 15 ++++++- .../lib/speed_limit/speed_limit_resolver.py | 5 ++- .../sunnypilot/sunnylink/capabilities.py | 4 ++ openpilot/sunnypilot/sunnylink/docs/README.md | 2 +- .../sunnypilot/sunnylink/settings_ui.json | 41 +++++++++++++++++-- .../settings_ui_src/pages/cruise.yaml | 19 +++++++++ 10 files changed, 97 insertions(+), 11 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py index 61254ce624..f698277202 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py @@ -8,6 +8,7 @@ from enum import IntEnum from openpilot.selfdrive.ui.sunnypilot.layouts.settings.cruise_sub_layouts.speed_limit_settings import SpeedLimitSettingsLayout from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.sunnypilot.mapd import MAPD_AVAILABLE from openpilot.system.ui.lib.multilang import tr, tr_noop from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp, option_item_sp, simple_button_item_sp from openpilot.system.ui.widgets import Widget @@ -29,6 +30,7 @@ ACC_ENABLED_DESCRIPTION = tr_noop("Enable custom Short & Long press increments f ACC_NOLONG_DESCRIPTION = tr_noop("This feature can only be used with sunnypilot longitudinal control enabled.") ACC_PCMCRUISE_DISABLED_DESCRIPTION = tr_noop("This feature is not supported on this platform due to vehicle limitations.") ONROAD_ONLY_DESCRIPTION = tr_noop("Start the vehicle to check vehicle compatibility.") +MAP_UNAVAILABLE_DESCRIPTION = tr_noop("Map data is temporarily unavailable.") class CruiseLayout(Widget): @@ -54,7 +56,8 @@ class CruiseLayout(Widget): self.scc_m_toggle = toggle_item_sp( title=tr("Smart Cruise Control - Map"), - description=tr("Use map data to estimate the appropriate speed to drive through turns ahead."), + description=(f"{tr(MAP_UNAVAILABLE_DESCRIPTION)}\n\n" if not MAPD_AVAILABLE else "") + + tr("Use map data to estimate the appropriate speed to drive through turns ahead."), param="SmartCruiseControlMap") self.custom_acc_toggle = toggle_item_sp( @@ -139,7 +142,7 @@ class CruiseLayout(Widget): if has_long or has_icbm: self.custom_acc_toggle.action_item.set_enabled(((has_long and not ui_state.CP.pcmCruise) or has_icbm) and ui_state.is_offroad()) self.scc_v_toggle.action_item.set_enabled(True) - self.scc_m_toggle.action_item.set_enabled(True) + self.scc_m_toggle.action_item.set_enabled(MAPD_AVAILABLE) else: ui_state.params.remove("CustomAccIncrementsEnabled") ui_state.params.remove("SmartCruiseControlVision") diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_policy.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_policy.py index e6846744a2..0af10af709 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_policy.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_policy.py @@ -8,6 +8,8 @@ from collections.abc import Callable import pyray as rl from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.sunnypilot.mapd import MAPD_AVAILABLE +from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Policy from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp from openpilot.system.ui.widgets.network import NavButton @@ -43,6 +45,8 @@ class SpeedLimitPolicyLayout(Widget): param="SpeedLimitPolicy", button_width=250, ) + if not MAPD_AVAILABLE: + self._speed_limit_policy.action_item.set_enabled_buttons({Policy.car_state_only}) items = [ self._speed_limit_policy @@ -51,6 +55,8 @@ class SpeedLimitPolicyLayout(Widget): @staticmethod def _get_policy_description(): + if not MAPD_AVAILABLE: + return f"{tr('Map data is temporarily unavailable. Using Speed Limit data only from Car.')}" return get_highlighted_description(ui_state.params, "SpeedLimitPolicy", SPEED_LIMIT_POLICY_DESCRIPTIONS) def _render(self, rect): diff --git a/openpilot/sunnypilot/mapd/__init__.py b/openpilot/sunnypilot/mapd/__init__.py index 57525254ce..3ca3bc08f5 100644 --- a/openpilot/sunnypilot/mapd/__init__.py +++ b/openpilot/sunnypilot/mapd/__init__.py @@ -3,3 +3,7 @@ from openpilot.common.basedir import BASEDIR MAPD_BIN_DIR = os.path.join(BASEDIR, 'openpilot', 'third_party/mapd_pfeiferj') MAPD_PATH = os.path.join(MAPD_BIN_DIR, 'mapd') + +# mapd still runs (OSM downloads) but has no position source since ubloxd owns LastGPSPosition; +# map-based features stay off until mapd is rebuilt +MAPD_AVAILABLE = False diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/map_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/map_controller.py index 0d85f060e1..99998e356b 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/map_controller.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/map_controller.py @@ -9,6 +9,7 @@ from openpilot.common.params import Params from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD +from openpilot.sunnypilot.mapd import MAPD_AVAILABLE from openpilot.sunnypilot.navd.helpers import Coordinate from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control import MIN_V @@ -75,7 +76,7 @@ class SmartCruiseControlMap: def __init__(self): self.params = Params() self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params - self.enabled = self.params.get_bool("SmartCruiseControlMap") + self.enabled = MAPD_AVAILABLE and self.params.get_bool("SmartCruiseControlMap") self.long_enabled = False self.long_override = False self.is_enabled = False @@ -101,7 +102,7 @@ class SmartCruiseControlMap: def update_params(self): if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: - self.enabled = self.params.get_bool("SmartCruiseControlMap") + self.enabled = MAPD_AVAILABLE and self.params.get_bool("SmartCruiseControlMap") def update_calculations(self, sm: messaging.SubMaster) -> None: gps = sm[self.gps_location_service] diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py index 720a3195e2..3744279b40 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py @@ -8,7 +8,7 @@ import json import math import platform from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from openpilot.cereal import custom from openpilot.common.params import Params @@ -23,6 +23,8 @@ MapState = VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.MapState class TestSmartCruiseControlMap(OpenpilotTestCase): def setup_method(self): + self.mapd_patch = patch("openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.map_controller.MAPD_AVAILABLE", True) + self.mapd_patch.start() self.params = Params() self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params self.reset_params() @@ -30,6 +32,9 @@ class TestSmartCruiseControlMap(OpenpilotTestCase): self.sm = MagicMock() self.sm.__getitem__.return_value = SimpleNamespace(latitude=0.0, longitude=0.0) + def teardown_method(self): + self.mapd_patch.stop() + def reset_params(self): self.params.put_bool("SmartCruiseControlMap", True, block=True) @@ -50,6 +55,14 @@ class TestSmartCruiseControlMap(OpenpilotTestCase): assert self.scc_m.state == VisionState.disabled assert not self.scc_m.is_active + def test_mapd_unavailable(self): + self.mapd_patch.stop() + self.scc_m = SmartCruiseControlMap() + for _ in range(int(10. / DT_MDL)): + self.scc_m.update(self.sm, True, False, 0., 0., 0.) + assert self.scc_m.state == VisionState.disabled + self.mapd_patch.start() + def test_disabled(self): for _ in range(int(10. / DT_MDL)): self.scc_m.update(self.sm, False, False, 0., 0., 0.) diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py index a226e0d120..0b53c766e4 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py @@ -13,6 +13,7 @@ from openpilot.common.gps import get_gps_location_service from openpilot.common.params import Params from openpilot.common.realtime import DT_MDL from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD, get_sanitize_int_param +from openpilot.sunnypilot.mapd import MAPD_AVAILABLE from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import LIMIT_MAX_MAP_DATA_AGE, LIMIT_ADAPT_ACC from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Policy, OffsetType @@ -48,6 +49,8 @@ class SpeedLimitResolver: Policy.max().value, self.params ) + if not MAPD_AVAILABLE: + self.policy = Policy.car_state_only self._policy_to_sources_map = { Policy.car_state_only: [SpeedLimitSource.car], Policy.map_data_only: [SpeedLimitSource.map], @@ -91,7 +94,7 @@ class SpeedLimitResolver: def update_params(self): if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: - self.policy = self.params.get("SpeedLimitPolicy", return_default=True) + self.policy = self.params.get("SpeedLimitPolicy", return_default=True) if MAPD_AVAILABLE else Policy.car_state_only self.is_metric = self.params.get_bool("IsMetric") self.offset_type = self.params.get("SpeedLimitOffsetType", return_default=True) self.offset_value = self.params.get("SpeedLimitValueOffset", return_default=True) diff --git a/openpilot/sunnypilot/sunnylink/capabilities.py b/openpilot/sunnypilot/sunnylink/capabilities.py index 8d303f0ae9..b7dc611a4e 100644 --- a/openpilot/sunnypilot/sunnylink/capabilities.py +++ b/openpilot/sunnypilot/sunnylink/capabilities.py @@ -15,6 +15,7 @@ from opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog from openpilot.common.hardware import HARDWARE +from openpilot.sunnypilot.mapd import MAPD_AVAILABLE # Wire-protocol version for the capabilities payload. Bump on breaking changes @@ -43,6 +44,7 @@ CAPABILITY_FIELDS = ( "device_type", "subaru_has_sng", "hyundai_alpha_long_available", + "mapd_available", ) CAPABILITY_LABELS: dict[str, str] = { @@ -65,6 +67,7 @@ CAPABILITY_LABELS: dict[str, str] = { "device_type": "Device type", "subaru_has_sng": "Subaru Stop-and-Go available", "hyundai_alpha_long_available": "Hyundai Alpha Longitudinal available", + "mapd_available": "Map data available", } # Explicit defaults for non-boolean capability fields @@ -132,6 +135,7 @@ def generate_capabilities(params: Params | None = None) -> dict: caps["is_sp_release"] = params.get_bool("IsReleaseSpBranch") caps["is_development"] = params.get_bool("IsDevelopmentBranch") caps["stock_longitudinal"] = params.get_bool("ToyotaEnforceStockLongitudinal") + caps["mapd_available"] = MAPD_AVAILABLE bundle = params.get("CarPlatformBundle") bundle_brand = _bundle_field(bundle, "brand") diff --git a/openpilot/sunnypilot/sunnylink/docs/README.md b/openpilot/sunnypilot/sunnylink/docs/README.md index cbc1eafbf4..4f283521d6 100644 --- a/openpilot/sunnypilot/sunnylink/docs/README.md +++ b/openpilot/sunnypilot/sunnylink/docs/README.md @@ -156,7 +156,7 @@ The tables below describe the **compiled** `settings_ui.json` schema — what th **Enablement rules**: Grayed out (disabled) when rules fail. Frontend shows a contextual badge explaining why. -**Capability fields** (referenced in rules): `has_longitudinal_control`, `has_icbm`, `icbm_available`, `torque_allowed`, `brand`, `pcm_cruise`, `alpha_long_available`, `steer_control_type`, `has_bsm`, `is_release`, `is_sp_release`, `is_development`, `tesla_has_vehicle_bus`, `has_stop_and_go`, `stock_longitudinal` +**Capability fields** (referenced in rules): `has_longitudinal_control`, `has_icbm`, `icbm_available`, `torque_allowed`, `brand`, `pcm_cruise`, `alpha_long_available`, `steer_control_type`, `has_bsm`, `is_release`, `is_sp_release`, `is_development`, `tesla_has_vehicle_bus`, `has_stop_and_go`, `stock_longitudinal`, `mapd_available` --- diff --git a/openpilot/sunnypilot/sunnylink/settings_ui.json b/openpilot/sunnypilot/sunnylink/settings_ui.json index c9d4c826f4..284473409c 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui.json +++ b/openpilot/sunnypilot/sunnylink/settings_ui.json @@ -857,19 +857,47 @@ }, { "value": 1, - "label": "Map Data Only" + "label": "Map Data Only", + "enablement": [ + { + "type": "capability", + "field": "mapd_available", + "equals": true + } + ] }, { "value": 2, - "label": "Car State Priority" + "label": "Car State Priority", + "enablement": [ + { + "type": "capability", + "field": "mapd_available", + "equals": true + } + ] }, { "value": 3, - "label": "Map Data Priority" + "label": "Map Data Priority", + "enablement": [ + { + "type": "capability", + "field": "mapd_available", + "equals": true + } + ] }, { "value": 4, - "label": "Combined" + "label": "Combined", + "enablement": [ + { + "type": "capability", + "field": "mapd_available", + "equals": true + } + ] } ] }, @@ -1001,6 +1029,11 @@ } ], "enablement": [ + { + "type": "capability", + "field": "mapd_available", + "equals": true + }, { "type": "any", "conditions": [ diff --git a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml index 39d6fbffd4..09efe4e0c6 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml +++ b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml @@ -167,12 +167,28 @@ sections: label: Car State Only - value: 1 label: Map Data Only + enablement: + - type: capability + field: mapd_available + equals: true - value: 2 label: Car State Priority + enablement: + - type: capability + field: mapd_available + equals: true - value: 3 label: Map Data Priority + enablement: + - type: capability + field: mapd_available + equals: true - value: 4 label: Combined + enablement: + - type: capability + field: mapd_available + equals: true - key: SpeedLimitOffsetType widget: multiple_button title: Speed Limit Offset Type @@ -246,6 +262,9 @@ sections: field: has_icbm equals: true enablement: + - type: capability + field: mapd_available + equals: true - type: any conditions: - type: capability From f0617e11a6e133e5fe168e1e8a899150c164e1b7 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:30:33 -0700 Subject: [PATCH 260/270] ci: fix default models (#2062) * ci: fix default models * shouldnt of been deleted * skip dm warp as well --- .github/workflows/build-default-models.yaml | 7 ++++--- openpilot/selfdrive/modeld/SConscript | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-default-models.yaml b/.github/workflows/build-default-models.yaml index f75c826851..01ebf0c977 100644 --- a/.github/workflows/build-default-models.yaml +++ b/.github/workflows/build-default-models.yaml @@ -115,7 +115,7 @@ jobs: env ${TG_FLAGS} python3 ${{ github.workspace }}/${{ env.COMPILER }}/compile_onnx.py \ ${{ github.workspace }}/${{ env.SMALL_ONNX }} \ ${{ github.workspace }}/${{ env.SMALL_PKL }} \ - --device-input "*" --out-of-band --benchmark-runs 1 + --out-of-band --benchmark-runs 1 - name: Prepare output env: @@ -440,9 +440,10 @@ jobs: TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1" taskset -c 7 env ${TG_FLAGS} python3 \ - ${{ github.workspace }}/tinygrad_repo/examples/openpilot/compile3.py \ + ${{ github.workspace }}/tinygrad_repo/examples/openpilot/compile_onnx.py \ ${{ github.workspace }}/${{ env.DM_ONNX }} \ - ${{ github.workspace }}/${{ env.DM_PKL }} + ${{ github.workspace }}/${{ env.DM_PKL }} \ + --out-of-band --benchmark-runs 1 - name: Compile DM warp run: | diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index c8892ef912..aec903235f 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -48,8 +48,9 @@ def compile_model(onnx_path, pkl_path): Action(cmd, " [ONNX] $TARGET"), ) -compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygrad.pkl') -compile_model('models/driving_supercombo.onnx', 'models/driving_tinygrad.pkl') +if not os.getenv('SKIP_TINYGRAD_COMPILE'): + compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygrad.pkl') + compile_model('models/driving_supercombo.onnx', 'models/driving_tinygrad.pkl') model_w, model_h = MEDMODEL_INPUT_SIZE for cam_w, cam_h in camera_configs: @@ -62,7 +63,7 @@ for cam_w, cam_h in camera_configs: lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], cmd) dm_w, dm_h = DM_INPUT_SIZE -for cam_w, cam_h in camera_configs: +for cam_w, cam_h in [] if os.getenv('SKIP_TINYGRAD_COMPILE') else camera_configs: dm_pkl_path = File(f"models/dm_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath stride, y_height, uv_height, frame_size = get_nv12_info(cam_w, cam_h) cmd = (f'{tg_flags} {mac_brew_string} python3 "{compiler}/compile_warp.py" ' From f09b04c267d01efddbd45457252a4884a5da0eca Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:14:06 -0700 Subject: [PATCH 261/270] ci: upload big model over lfs (#2063) ci: push chestnut via lfs --- .github/workflows/sunnypilot-build-prebuilt.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index 78305f3fb4..29e03a8437 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -594,8 +594,14 @@ jobs: - name: Publish chestnut branch env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | CHESTNUT_BRANCH="${{ needs.prepare_strategy.outputs.new_branch }}-chestnut" + cp .lfsconfig "${{ env.OUTPUT_DIR }}/" + echo 'openpilot/selfdrive/modeld/models/big_*_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text' > "${{ env.OUTPUT_DIR }}/.gitattributes" + git lfs install + git config --global credential.helper store + echo "https://user:${HF_TOKEN}@huggingface.co" > ~/.git-credentials ${{ env.CI_DIR }}/publish.sh \ "${{ github.workspace }}" \ From 0120b66f05d1f29ddc79b5f13d16f7214a3ccb26 Mon Sep 17 00:00:00 2001 From: Amy Jeanes Date: Mon, 19 Oct 2026 00:16:49 +0100 Subject: [PATCH 262/270] modeld: set AM_PERF_LEVEL to high for perf (#2061) set AM_PERF_LEVEL to high for perf Co-authored-by: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> --- openpilot/selfdrive/modeld/modeld.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 27693bce6c..7e8c705abc 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -7,6 +7,7 @@ from functools import cached_property import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom os.environ.setdefault('AM_POWER_LIMIT', '100') +os.environ.setdefault('AM_PERF_LEVEL', 'high') from tinygrad.device import Buffer, Device from tinygrad.dtype import DType, dtypes from tinygrad.engine.realize import lower_and_compile From 09b2f4bbe03460513ef2ec0c267f65fe2f69f990 Mon Sep 17 00:00:00 2001 From: Kumar <36933347+rav4kumar@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:07:32 -0700 Subject: [PATCH 263/270] mapd: fix mapd GPS position handling (#2064) * mapd: restore the position mapd reads from llk * turn it on * mapd ui * Revert "mapd ui" This reverts commit 857d4302972bbd6b186e60174f75cb4cbaf96226. * Revert "turn it on" This reverts commit 2a5d9a62beea943ea589c62a5c19f6b8a1e451b4. --- .../mapd/live_map_data/osm_map_data.py | 16 +++++ openpilot/sunnypilot/mapd/mapd_manager.py | 4 ++ .../mapd/tests/test_osm_map_data.py | 70 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 openpilot/sunnypilot/mapd/tests/test_osm_map_data.py diff --git a/openpilot/sunnypilot/mapd/live_map_data/osm_map_data.py b/openpilot/sunnypilot/mapd/live_map_data/osm_map_data.py index c22fb7da40..3073b372a2 100644 --- a/openpilot/sunnypilot/mapd/live_map_data/osm_map_data.py +++ b/openpilot/sunnypilot/mapd/live_map_data/osm_map_data.py @@ -4,6 +4,7 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ +import json import math import platform @@ -26,6 +27,21 @@ class OsmMapData(BaseMapData): self.last_bearing = math.degrees(location.calibratedOrientationNED.value[2]) self.last_position = Coordinate(location.positionGeodetic.value[0], location.positionGeodetic.value[1]) + if self.last_position is None: + return + + params = { + "latitude": self.last_position.latitude, + "longitude": self.last_position.longitude, + } + + if self.last_bearing is not None: + params['bearing'] = self.last_bearing + + # mapd reads its position from the memory params as JSON, so write it for the mapd binary. + # LastGPSPosition is registered as BYTES (ubloxd owns the persistent copy for pigeond), + # so the JSON payload has to be encoded here. + self.mem_params.put("LastGPSPosition", json.dumps(params).encode(), block=True) def get_current_speed_limit(self) -> float: return float(self.mem_params.get("MapSpeedLimit") or 0.0) diff --git a/openpilot/sunnypilot/mapd/mapd_manager.py b/openpilot/sunnypilot/mapd/mapd_manager.py index 886a1c6068..d6acd529a7 100755 --- a/openpilot/sunnypilot/mapd/mapd_manager.py +++ b/openpilot/sunnypilot/mapd/mapd_manager.py @@ -121,6 +121,10 @@ def update_osm_db() -> None: if not mem_params.get("OSMDownloadBounds"): mem_params.put("OSMDownloadBounds", "", block=True) + # mapd reads LastGPSPosition from the memory params as JSON, and it is registered as BYTES upstream + if not mem_params.get("LastGPSPosition"): + mem_params.put("LastGPSPosition", b"{}", block=True) + def main_thread(): update_installed_version(VERSION, params) diff --git a/openpilot/sunnypilot/mapd/tests/test_osm_map_data.py b/openpilot/sunnypilot/mapd/tests/test_osm_map_data.py new file mode 100644 index 0000000000..20b755bcb9 --- /dev/null +++ b/openpilot/sunnypilot/mapd/tests/test_osm_map_data.py @@ -0,0 +1,70 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import json +import math +import platform + +import openpilot.cereal.messaging as messaging +from openpilot.cereal import log +from openpilot.common.params import Params +from openpilot.common.test import OpenpilotTestCase +from openpilot.sunnypilot.mapd.live_map_data.osm_map_data import OsmMapData + + +class TestOsmMapData(OpenpilotTestCase): + """The mapd binary reads its position from the memory params as JSON, so mapd_manager has to feed it.""" + + LATITUDE = 32.875134 + LONGITUDE = -117.209777 + ALTITUDE = 42.0 + BEARING = 90.0 + + def setup_method(self): + self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else Params() + self.mem_params.remove("LastGPSPosition") + self.map_data = OsmMapData() + self.pm = messaging.PubMaster(['liveLocationKalman']) + + def location_message(self, status: log.LiveLocationKalman.Status, position_valid: bool): + msg = messaging.new_message('liveLocationKalman', valid=True) + location = msg.liveLocationKalman + location.status = status + location.positionGeodetic = {'value': [self.LATITUDE, self.LONGITUDE, self.ALTITUDE], 'valid': position_valid} + location.calibratedOrientationNED = {'value': [0.0, 0.0, math.radians(self.BEARING)], 'valid': True} + return msg + + def send_location(self, status: log.LiveLocationKalman.Status, position_valid: bool) -> None: + # the first exchange synchronizes the PUB/SUB connection, the second one is actually received + self.pm.send('liveLocationKalman', self.location_message(status, position_valid)) + self.map_data.sm.update(1000) + self.pm.send('liveLocationKalman', self.location_message(status, position_valid)) + self.map_data.sm.update(1000) + + def read_position(self) -> dict: + value = self.mem_params.get("LastGPSPosition") + self.assertIsNotNone(value, "LastGPSPosition was not written for mapd") + return json.loads(value) + + def test_writes_position_for_mapd(self): + self.send_location(log.LiveLocationKalman.Status.valid, True) + self.map_data.tick() + + position = self.read_position() + self.assertAlmostEqual(position['latitude'], self.LATITUDE, delta=1e-6) + self.assertAlmostEqual(position['longitude'], self.LONGITUDE, delta=1e-6) + self.assertAlmostEqual(position['bearing'], self.BEARING, delta=1e-6) + + def test_invalid_localizer_keeps_last_known_position(self): + self.send_location(log.LiveLocationKalman.Status.valid, True) + self.map_data.tick() + + self.send_location(log.LiveLocationKalman.Status.uninitialized, False) + self.map_data.tick() + + position = self.read_position() + self.assertAlmostEqual(position['latitude'], self.LATITUDE, delta=1e-6) + self.assertAlmostEqual(position['longitude'], self.LONGITUDE, delta=1e-6) From 0dacd0eec4acd0383a8418e3f8b8b95dab47c4c7 Mon Sep 17 00:00:00 2001 From: Kumar <36933347+rav4kumar@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:25:14 -0700 Subject: [PATCH 264/270] Revert "mapd: disable map-based Smart Cruise Control and speed limit sources until mapd has a position source" (#2065) This reverts commit b6170ac5e91f2b019df0cfa459a1a37f9c4a13a7. --- .../ui/sunnypilot/layouts/settings/cruise.py | 7 +--- .../cruise_sub_layouts/speed_limit_policy.py | 6 --- openpilot/sunnypilot/mapd/__init__.py | 4 -- .../smart_cruise_control/map_controller.py | 5 +-- .../tests/test_map_controller.py | 15 +------ .../lib/speed_limit/speed_limit_resolver.py | 5 +-- .../sunnypilot/sunnylink/capabilities.py | 4 -- openpilot/sunnypilot/sunnylink/docs/README.md | 2 +- .../sunnypilot/sunnylink/settings_ui.json | 41 ++----------------- .../settings_ui_src/pages/cruise.yaml | 19 --------- 10 files changed, 11 insertions(+), 97 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py index f698277202..61254ce624 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise.py @@ -8,7 +8,6 @@ from enum import IntEnum from openpilot.selfdrive.ui.sunnypilot.layouts.settings.cruise_sub_layouts.speed_limit_settings import SpeedLimitSettingsLayout from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.sunnypilot.mapd import MAPD_AVAILABLE from openpilot.system.ui.lib.multilang import tr, tr_noop from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp, option_item_sp, simple_button_item_sp from openpilot.system.ui.widgets import Widget @@ -30,7 +29,6 @@ ACC_ENABLED_DESCRIPTION = tr_noop("Enable custom Short & Long press increments f ACC_NOLONG_DESCRIPTION = tr_noop("This feature can only be used with sunnypilot longitudinal control enabled.") ACC_PCMCRUISE_DISABLED_DESCRIPTION = tr_noop("This feature is not supported on this platform due to vehicle limitations.") ONROAD_ONLY_DESCRIPTION = tr_noop("Start the vehicle to check vehicle compatibility.") -MAP_UNAVAILABLE_DESCRIPTION = tr_noop("Map data is temporarily unavailable.") class CruiseLayout(Widget): @@ -56,8 +54,7 @@ class CruiseLayout(Widget): self.scc_m_toggle = toggle_item_sp( title=tr("Smart Cruise Control - Map"), - description=(f"{tr(MAP_UNAVAILABLE_DESCRIPTION)}\n\n" if not MAPD_AVAILABLE else "") + - tr("Use map data to estimate the appropriate speed to drive through turns ahead."), + description=tr("Use map data to estimate the appropriate speed to drive through turns ahead."), param="SmartCruiseControlMap") self.custom_acc_toggle = toggle_item_sp( @@ -142,7 +139,7 @@ class CruiseLayout(Widget): if has_long or has_icbm: self.custom_acc_toggle.action_item.set_enabled(((has_long and not ui_state.CP.pcmCruise) or has_icbm) and ui_state.is_offroad()) self.scc_v_toggle.action_item.set_enabled(True) - self.scc_m_toggle.action_item.set_enabled(MAPD_AVAILABLE) + self.scc_m_toggle.action_item.set_enabled(True) else: ui_state.params.remove("CustomAccIncrementsEnabled") ui_state.params.remove("SmartCruiseControlVision") diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_policy.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_policy.py index 0af10af709..e6846744a2 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_policy.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/cruise_sub_layouts/speed_limit_policy.py @@ -8,8 +8,6 @@ from collections.abc import Callable import pyray as rl from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.sunnypilot.mapd import MAPD_AVAILABLE -from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Policy from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp from openpilot.system.ui.widgets.network import NavButton @@ -45,8 +43,6 @@ class SpeedLimitPolicyLayout(Widget): param="SpeedLimitPolicy", button_width=250, ) - if not MAPD_AVAILABLE: - self._speed_limit_policy.action_item.set_enabled_buttons({Policy.car_state_only}) items = [ self._speed_limit_policy @@ -55,8 +51,6 @@ class SpeedLimitPolicyLayout(Widget): @staticmethod def _get_policy_description(): - if not MAPD_AVAILABLE: - return f"{tr('Map data is temporarily unavailable. Using Speed Limit data only from Car.')}" return get_highlighted_description(ui_state.params, "SpeedLimitPolicy", SPEED_LIMIT_POLICY_DESCRIPTIONS) def _render(self, rect): diff --git a/openpilot/sunnypilot/mapd/__init__.py b/openpilot/sunnypilot/mapd/__init__.py index 3ca3bc08f5..57525254ce 100644 --- a/openpilot/sunnypilot/mapd/__init__.py +++ b/openpilot/sunnypilot/mapd/__init__.py @@ -3,7 +3,3 @@ from openpilot.common.basedir import BASEDIR MAPD_BIN_DIR = os.path.join(BASEDIR, 'openpilot', 'third_party/mapd_pfeiferj') MAPD_PATH = os.path.join(MAPD_BIN_DIR, 'mapd') - -# mapd still runs (OSM downloads) but has no position source since ubloxd owns LastGPSPosition; -# map-based features stay off until mapd is rebuilt -MAPD_AVAILABLE = False diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/map_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/map_controller.py index 99998e356b..0d85f060e1 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/map_controller.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/map_controller.py @@ -9,7 +9,6 @@ from openpilot.common.params import Params from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD -from openpilot.sunnypilot.mapd import MAPD_AVAILABLE from openpilot.sunnypilot.navd.helpers import Coordinate from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control import MIN_V @@ -76,7 +75,7 @@ class SmartCruiseControlMap: def __init__(self): self.params = Params() self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params - self.enabled = MAPD_AVAILABLE and self.params.get_bool("SmartCruiseControlMap") + self.enabled = self.params.get_bool("SmartCruiseControlMap") self.long_enabled = False self.long_override = False self.is_enabled = False @@ -102,7 +101,7 @@ class SmartCruiseControlMap: def update_params(self): if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: - self.enabled = MAPD_AVAILABLE and self.params.get_bool("SmartCruiseControlMap") + self.enabled = self.params.get_bool("SmartCruiseControlMap") def update_calculations(self, sm: messaging.SubMaster) -> None: gps = sm[self.gps_location_service] diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py index 3744279b40..720a3195e2 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py @@ -8,7 +8,7 @@ import json import math import platform from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from openpilot.cereal import custom from openpilot.common.params import Params @@ -23,8 +23,6 @@ MapState = VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.MapState class TestSmartCruiseControlMap(OpenpilotTestCase): def setup_method(self): - self.mapd_patch = patch("openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.map_controller.MAPD_AVAILABLE", True) - self.mapd_patch.start() self.params = Params() self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params self.reset_params() @@ -32,9 +30,6 @@ class TestSmartCruiseControlMap(OpenpilotTestCase): self.sm = MagicMock() self.sm.__getitem__.return_value = SimpleNamespace(latitude=0.0, longitude=0.0) - def teardown_method(self): - self.mapd_patch.stop() - def reset_params(self): self.params.put_bool("SmartCruiseControlMap", True, block=True) @@ -55,14 +50,6 @@ class TestSmartCruiseControlMap(OpenpilotTestCase): assert self.scc_m.state == VisionState.disabled assert not self.scc_m.is_active - def test_mapd_unavailable(self): - self.mapd_patch.stop() - self.scc_m = SmartCruiseControlMap() - for _ in range(int(10. / DT_MDL)): - self.scc_m.update(self.sm, True, False, 0., 0., 0.) - assert self.scc_m.state == VisionState.disabled - self.mapd_patch.start() - def test_disabled(self): for _ in range(int(10. / DT_MDL)): self.scc_m.update(self.sm, False, False, 0., 0., 0.) diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py index 0b53c766e4..a226e0d120 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/speed_limit_resolver.py @@ -13,7 +13,6 @@ from openpilot.common.gps import get_gps_location_service from openpilot.common.params import Params from openpilot.common.realtime import DT_MDL from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD, get_sanitize_int_param -from openpilot.sunnypilot.mapd import MAPD_AVAILABLE from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import LIMIT_MAX_MAP_DATA_AGE, LIMIT_ADAPT_ACC from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Policy, OffsetType @@ -49,8 +48,6 @@ class SpeedLimitResolver: Policy.max().value, self.params ) - if not MAPD_AVAILABLE: - self.policy = Policy.car_state_only self._policy_to_sources_map = { Policy.car_state_only: [SpeedLimitSource.car], Policy.map_data_only: [SpeedLimitSource.map], @@ -94,7 +91,7 @@ class SpeedLimitResolver: def update_params(self): if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: - self.policy = self.params.get("SpeedLimitPolicy", return_default=True) if MAPD_AVAILABLE else Policy.car_state_only + self.policy = self.params.get("SpeedLimitPolicy", return_default=True) self.is_metric = self.params.get_bool("IsMetric") self.offset_type = self.params.get("SpeedLimitOffsetType", return_default=True) self.offset_value = self.params.get("SpeedLimitValueOffset", return_default=True) diff --git a/openpilot/sunnypilot/sunnylink/capabilities.py b/openpilot/sunnypilot/sunnylink/capabilities.py index b7dc611a4e..8d303f0ae9 100644 --- a/openpilot/sunnypilot/sunnylink/capabilities.py +++ b/openpilot/sunnypilot/sunnylink/capabilities.py @@ -15,7 +15,6 @@ from opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog from openpilot.common.hardware import HARDWARE -from openpilot.sunnypilot.mapd import MAPD_AVAILABLE # Wire-protocol version for the capabilities payload. Bump on breaking changes @@ -44,7 +43,6 @@ CAPABILITY_FIELDS = ( "device_type", "subaru_has_sng", "hyundai_alpha_long_available", - "mapd_available", ) CAPABILITY_LABELS: dict[str, str] = { @@ -67,7 +65,6 @@ CAPABILITY_LABELS: dict[str, str] = { "device_type": "Device type", "subaru_has_sng": "Subaru Stop-and-Go available", "hyundai_alpha_long_available": "Hyundai Alpha Longitudinal available", - "mapd_available": "Map data available", } # Explicit defaults for non-boolean capability fields @@ -135,7 +132,6 @@ def generate_capabilities(params: Params | None = None) -> dict: caps["is_sp_release"] = params.get_bool("IsReleaseSpBranch") caps["is_development"] = params.get_bool("IsDevelopmentBranch") caps["stock_longitudinal"] = params.get_bool("ToyotaEnforceStockLongitudinal") - caps["mapd_available"] = MAPD_AVAILABLE bundle = params.get("CarPlatformBundle") bundle_brand = _bundle_field(bundle, "brand") diff --git a/openpilot/sunnypilot/sunnylink/docs/README.md b/openpilot/sunnypilot/sunnylink/docs/README.md index 4f283521d6..cbc1eafbf4 100644 --- a/openpilot/sunnypilot/sunnylink/docs/README.md +++ b/openpilot/sunnypilot/sunnylink/docs/README.md @@ -156,7 +156,7 @@ The tables below describe the **compiled** `settings_ui.json` schema — what th **Enablement rules**: Grayed out (disabled) when rules fail. Frontend shows a contextual badge explaining why. -**Capability fields** (referenced in rules): `has_longitudinal_control`, `has_icbm`, `icbm_available`, `torque_allowed`, `brand`, `pcm_cruise`, `alpha_long_available`, `steer_control_type`, `has_bsm`, `is_release`, `is_sp_release`, `is_development`, `tesla_has_vehicle_bus`, `has_stop_and_go`, `stock_longitudinal`, `mapd_available` +**Capability fields** (referenced in rules): `has_longitudinal_control`, `has_icbm`, `icbm_available`, `torque_allowed`, `brand`, `pcm_cruise`, `alpha_long_available`, `steer_control_type`, `has_bsm`, `is_release`, `is_sp_release`, `is_development`, `tesla_has_vehicle_bus`, `has_stop_and_go`, `stock_longitudinal` --- diff --git a/openpilot/sunnypilot/sunnylink/settings_ui.json b/openpilot/sunnypilot/sunnylink/settings_ui.json index 284473409c..c9d4c826f4 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui.json +++ b/openpilot/sunnypilot/sunnylink/settings_ui.json @@ -857,47 +857,19 @@ }, { "value": 1, - "label": "Map Data Only", - "enablement": [ - { - "type": "capability", - "field": "mapd_available", - "equals": true - } - ] + "label": "Map Data Only" }, { "value": 2, - "label": "Car State Priority", - "enablement": [ - { - "type": "capability", - "field": "mapd_available", - "equals": true - } - ] + "label": "Car State Priority" }, { "value": 3, - "label": "Map Data Priority", - "enablement": [ - { - "type": "capability", - "field": "mapd_available", - "equals": true - } - ] + "label": "Map Data Priority" }, { "value": 4, - "label": "Combined", - "enablement": [ - { - "type": "capability", - "field": "mapd_available", - "equals": true - } - ] + "label": "Combined" } ] }, @@ -1029,11 +1001,6 @@ } ], "enablement": [ - { - "type": "capability", - "field": "mapd_available", - "equals": true - }, { "type": "any", "conditions": [ diff --git a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml index 09efe4e0c6..39d6fbffd4 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml +++ b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml @@ -167,28 +167,12 @@ sections: label: Car State Only - value: 1 label: Map Data Only - enablement: - - type: capability - field: mapd_available - equals: true - value: 2 label: Car State Priority - enablement: - - type: capability - field: mapd_available - equals: true - value: 3 label: Map Data Priority - enablement: - - type: capability - field: mapd_available - equals: true - value: 4 label: Combined - enablement: - - type: capability - field: mapd_available - equals: true - key: SpeedLimitOffsetType widget: multiple_button title: Speed Limit Offset Type @@ -262,9 +246,6 @@ sections: field: has_icbm equals: true enablement: - - type: capability - field: mapd_available - equals: true - type: any conditions: - type: capability From 922513f3128b00611d695ce465989a9bc8af9d59 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:41:23 -0700 Subject: [PATCH 265/270] ci: lfs pointers survive gc (#2066) * ci: lfs pointers survive gc * comma got gc'ed * format --- .github/workflows/lfs-maintenance.yaml | 80 +++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lfs-maintenance.yaml b/.github/workflows/lfs-maintenance.yaml index 3236d90e13..4c98c24722 100644 --- a/.github/workflows/lfs-maintenance.yaml +++ b/.github/workflows/lfs-maintenance.yaml @@ -33,7 +33,7 @@ jobs: - name: LFS Fetch run: | - git lfs fetch + git lfs fetch || echo "::warning::some objects are missing on commaai/openpilot-lfs; mirroring the rest" - name: Set up Git run: | @@ -68,6 +68,7 @@ jobs: git config lfs.activitytimeout 300 git config lfs.transfer.maxretries 5 git config lfs.concurrenttransfers 4 + git config lfs.allowincompletepush true - name: Push LFS id: sync-and-commit @@ -81,3 +82,80 @@ jobs: done echo "All push attempts failed" exit 1 + + - name: Checkout sunnypilot + uses: actions/checkout@v4 + with: + repository: 'sunnypilot/sunnypilot' + path: sunnypilot + fetch-depth: 0 + filter: blob:limit=1k + lfs: false + + - name: Reference objects on sunnypilot-lfs + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + set -euo pipefail + GIT_LFS_SKIP_SMUDGE=1 git clone --quiet https://huggingface.co/sunnypilot/sunnypilot-lfs hf-lfs + mkdir -p hf-lfs/all + cat > scan_pointers.py <<'EOF' + import re, subprocess, sys, threading + repo = sys.argv[1] + chk = subprocess.run(["git", "-C", repo, "cat-file", "--batch-all-objects", + "--batch-check=%(objectname) %(objecttype) %(objectsize)"], + capture_output=True, text=True, check=True).stdout + small = [f[0] for f in (l.split() for l in chk.splitlines()) if f[1] == "blob" and int(f[2]) < 1024] + p = subprocess.Popen(["git", "-C", repo, "cat-file", "--batch=%(objectsize)"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE) + def feed(): + for sha in small: + p.stdin.write(sha.encode() + b"\n") + p.stdin.close() + threading.Thread(target=feed, daemon=True).start() + rx = re.compile(rb"^version https://git-lfs\.github\.com/spec/v1\noid sha256:([0-9a-f]{64})\nsize ([0-9]+)\n") + seen = {} + for _ in small: + n = int(p.stdout.readline()); body = p.stdout.read(n); p.stdout.read(1) + m = rx.match(body) + if m: + seen.setdefault(m.group(1).decode(), m.group(2).decode()) + for oid, size in sorted(seen.items()): + print(oid, size) + EOF + for repo in . sunnypilot; do + python3 scan_pointers.py "$repo" + done | sort -u | while read -r oid size; do + [ -e "hf-lfs/all/$oid" ] && continue + printf 'version https://git-lfs.github.com/spec/v1\noid sha256:%s\nsize %s\n' "$oid" "$size" > "hf-lfs/all/$oid" + echo "$oid" + done > new_oids.txt + echo "new pointer files: $(wc -l < new_oids.txt)" + + dropped=0 + while mapfile -t -n 100 chunk && ((${#chunk[@]})); do + body=$(for oid in "${chunk[@]}"; do + size=$(awk '/^size /{print $2}' "hf-lfs/all/$oid") + printf '{"oid":"%s","size":%s}\n' "$oid" "$size" + done | jq -s '{operation:"download",transfers:["basic"],objects:.}') + for attempt in 1 2 3 4 5; do + resp=$(curl -sS -u "user:${HF_TOKEN}" -w '\n%{http_code}' \ + -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' \ + --data "$body" "${LFS_URL}/objects/batch") && [ "$(tail -n1 <<<"$resp")" = 200 ] && break + echo "batch check attempt $attempt failed ($(tail -n1 <<<"$resp")), retrying in 60s"; sleep 60 + done + [ "$(tail -n1 <<<"$resp")" = 200 ] || { echo "batch check failed"; exit 1; } + for oid in $(sed '$d' <<<"$resp" | jq -r '.objects[] | select(.error) | .oid'); do + rm -f "hf-lfs/all/$oid"; dropped=$((dropped+1)) + done + done < new_oids.txt + echo "dropped (not on HF): $dropped" + + cd hf-lfs + git add -A all + if git diff --cached --quiet; then + echo "nothing new to reference" + exit 0 + fi + git commit -q -m "Reference $(git diff --cached --name-only | wc -l | tr -d ' ') new LFS objects" + git push origin HEAD:main From ddd7b14ebca76ccf92d59dd7d8aba391c1231a1a Mon Sep 17 00:00:00 2001 From: Amy Jeanes Date: Wed, 21 Oct 2026 17:35:43 +0100 Subject: [PATCH 266/270] models: download whole-file artifacts with parallel byte ranges (#2051) * models: download whole-file artifacts with parallel byte ranges HuggingFace's Xet CAS throttles each connection to roughly 1-2 MB/s but does not limit connection count, so the single-stream _download_file left the link idle most of the time. Replace it with a downloader that probes the size with a one-byte Range request, creates the file at its final size and streams each 8 MB piece straight to its offset from one of 12 worker threads. The file is never copied or assembled and only ever occupies its own size on disk. A server that answers the probe with 200 gets one plain stream instead. Every request sends Accept-Encoding: identity, because byte ranges count encoded bytes while requests hands back decoded ones, so a compressed response would land at the wrong offsets and fail as a short read. Workers only write bytes; a reporter coroutine on the event loop publishes progress every 0.5 s (instead of every 128 KB), re-raises the first worker failure and polls the cancel ref. Transient errors (connection, timeout, truncated body, HTTP 408/429/500/502/503/504) give a piece up to three attempts; any other HTTP error fails the download at once. The whole-file path now also publishes "verifying" while hashing before and after the transfer, and the download is hashed against the manifest exactly as before. A cancel has to wait for in-flight pieces to unwind (a blocked socket read cannot be interrupted), so the row shows "canceling..." through a new DownloadProgress.canceling flag instead of a frozen bar, and the mici cancel button hides once the ref is gone, as it already does on the big UI. DownloadProgress gains speed, canceling and resuming, appended to the capnp so older readers are unaffected. All three are declared here so the wire format lands in one place; speed and resuming are filled in by the follow-up speed and resume changes. Builds on #2050, which drops chunking and publishes models as whole files; this makes that whole-file download parallel. Measured on a comma 3X on WiFi: ~2 MB/s single stream, 17-24 MB/s steady and 30-60 MB/s peak with 12 range connections, the count picked from an on-device sweep. Disk is not the bottleneck (eMMC writes at 56-67 MB/s with fdatasync). Tests drive the real downloader against a local threaded range server: piece tiling, actual parallelism (a stalled piece does not block the others), the no-range fallback, per-piece retry, 4xx, cancel via ModelManager_DownloadRef and mid-piece, verify/cached/downloaded state, a probe failure moving past verifying, and hash mismatch cleanup. Also drops two chunk-era leftovers in the tests: a stale "no chunks" note on manifest_bundle and a one-item URL loop in the live manifest check. Assisted-by: Claude Code (Claude Opus 4.8) Assisted-by: Claude Code (Claude Opus 5.5) * remove unneeded comments * models: trim the manager tests to the regressions they catch Each remaining test is there because a deliberate break in manager.py, helpers.py or fetcher.py fails it. Tests that only re-checked what another already catches are merged into a happy-path download or a table, and checks of constants rather than behaviour are dropped. The fake server now starts per test, keeps its state in one dataclass, and can cut a response short. Against 66 deliberate breaks, every one the old tests caught still fails, and a 404 being retried, a short read and its retry, and the display flags choosing the big model without a GPU are now caught too. Assisted-by: Claude Code (Claude Opus 5.5) * Update test_manager_download.py * Update manager.py --------- Co-authored-by: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> --- openpilot/cereal/custom.capnp | 3 + .../ui/sunnypilot/layouts/settings/models.py | 2 + .../ui/sunnypilot/mici/layouts/models.py | 7 +- openpilot/sunnypilot/models/manager.py | 194 +++- .../models/tests/test_manager_download.py | 847 +++++++----------- .../ui/sunnypilot/widgets/download_status.py | 2 +- 6 files changed, 496 insertions(+), 559 deletions(-) diff --git a/openpilot/cereal/custom.capnp b/openpilot/cereal/custom.capnp index b8c833ed80..d0e9dd463c 100644 --- a/openpilot/cereal/custom.capnp +++ b/openpilot/cereal/custom.capnp @@ -138,6 +138,9 @@ struct ModelManagerSP @0xaedffd8f31e7b55d { status @0 :DownloadStatus; progress @1 :Float32; eta @2 :UInt32; + speed @3 :Float32; + canceling @4 :Bool; + resuming @5 :Bool; } struct Chunk { diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index d036d8d281..1d12f9f5b1 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -235,6 +235,8 @@ class ModelsLayout(Widget): progress = sum(p.progress for p in progresses) / len(progresses) ds = custom.ModelManagerSP.DownloadStatus + if any(p.canceling for p in progresses): + return {"name": name, "downloading": True, "progress": progress, "status_text": tr("canceling...")} if ds.failed in statuses: # close.png is authored black and a tint cannot lift it, hence close2 return {"name": name, "status_text": tr("download failed"), "text_color": rl.RED, "icon": "icons/close2.png"} diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py index 404ff2e305..99e0b06e87 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -251,11 +251,12 @@ class ModelsLayoutMici(NavScroller): self.current_model_info.info_text.set_text(tr("download failed")) elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading: - self.cancel_download_btn.set_visible(True) + self.cancel_download_btn.set_visible(ui_state.params.get("ModelManager_DownloadRef") is not None) device.set_override_interactive_timeout(5) progress = 0.0 count = 0 verifying = False + canceling = False for model in manager.selectedBundle.models: count += 1 p = model.artifact.downloadProgress @@ -263,11 +264,13 @@ class ModelsLayoutMici(NavScroller): custom.ModelManagerSP.DownloadStatus.verifying): progress += p.progress verifying = verifying or p.status == custom.ModelManagerSP.DownloadStatus.verifying + canceling = canceling or p.canceling elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded, custom.ModelManagerSP.DownloadStatus.cached): progress += 100.0 - self.current_model_info.current_model_header.set_text(tr("verifying") if verifying else tr("downloading")) + header = tr("canceling...") if canceling else tr("verifying") if verifying else tr("downloading") + self.current_model_info.current_model_header.set_text(header) self.cancel_download_btn.set_text(tr("cancel verification") if verifying else tr("cancel download")) self.current_model_info.current_model_header._shimmer = True name_text = manager.selectedBundle.internalName.lower() diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index 66b0fe28ab..bdee8ac94a 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -7,7 +7,10 @@ See the LICENSE.md file in the root directory for more details. import asyncio import os +import re +import threading import time +from concurrent.futures import ThreadPoolExecutor import requests from openpilot.common.params import Params @@ -22,12 +25,99 @@ from openpilot.sunnypilot.models.helpers import (ACTIVE_BUNDLE_KEYS, get_active_ # (connect, read) seconds. read is per-request inactivity, not a total cap DOWNLOAD_TIMEOUT = (30, 30) +# HuggingFace throttles each connection to ~1-2 MB/s but not the connection count; 12 saturates the device link +MAX_CONCURRENT_PIECES = 12 +PIECE_SIZE = 8 * 1024 * 1024 +PIECE_ATTEMPTS = 3 +PIECE_BACKOFF = 1.0 # seconds, growing linearly: waits 1 s then 2 s between a piece's three attempts +TRANSIENT_HTTP_STATUS = frozenset({408, 429, 500, 502, 503, 504}) +REPORT_INTERVAL = 0.5 # seconds between progress publications +# byte ranges count encoded bytes but iter_content yields decoded ones, so ask for an unencoded body +NO_COMPRESSION = {"Accept-Encoding": "identity"} class DownloadCancelled(Exception): pass +def _is_transient(e: BaseException) -> bool: + if isinstance(e, requests.HTTPError): + return e.response is not None and e.response.status_code in TRANSIENT_HTTP_STATUS + return isinstance(e, (requests.ConnectionError, requests.Timeout, requests.exceptions.ChunkedEncodingError)) + + +class _Progress: + def __init__(self): + self._bytes = 0 + self._lock = threading.Lock() + + def add_bytes(self, n: int) -> None: + with self._lock: + self._bytes += n + + def snapshot(self) -> int: + with self._lock: + return self._bytes + + +def _piece_ranges(total: int, piece_size: int) -> list[tuple[int, int]]: + return [(start, min(start + piece_size, total)) for start in range(0, total, piece_size)] + + +def _prepare_target(path: str, total: int) -> None: + with open(path, "wb") as f: + f.truncate(total) + + +def _content_range_total(response: requests.Response) -> int | None: + if response.status_code != 206: + return None + match = re.fullmatch(r"bytes \d+-\d+/(\d+)", response.headers.get("Content-Range", "")) + return int(match.group(1)) if match else None + + +def _probe_size(url: str) -> tuple[int, bool]: + with requests.get(url, headers={**NO_COMPRESSION, "Range": "bytes=0-0"}, stream=True, timeout=DOWNLOAD_TIMEOUT) as response: + response.raise_for_status() + total = _content_range_total(response) + if total is not None: + return total, True + return int(response.headers.get("Content-Length", 0)), False + + +def _fetch_piece(url: str, path: str, index: int, start: int, end: int | None, progress: _Progress, + cancel: threading.Event, block_size: int) -> None: + headers = {**NO_COMPRESSION, "Range": f"bytes={start}-{end - 1}"} if end is not None else NO_COMPRESSION + label = f"{os.path.basename(path)} piece {index}" + for attempt in range(PIECE_ATTEMPTS): + written = 0 + try: + with requests.get(url, headers=headers, stream=True, timeout=DOWNLOAD_TIMEOUT) as response: + response.raise_for_status() + if end is not None and response.status_code != 206: + raise ValueError(f"server ignored byte range for {label}") + with open(path, "r+b") as f: + f.seek(start) + for block in response.iter_content(chunk_size=block_size): + if cancel.is_set(): + raise DownloadCancelled("Download cancelled") + f.write(block) + written += len(block) + progress.add_bytes(len(block)) + if end is None: + f.truncate() # unknown length: the body defines the file size + if end is not None and written != end - start: + raise requests.exceptions.ConnectionError(f"short read: {written} of {end - start} bytes") + return + except requests.RequestException as e: + progress.add_bytes(-written) # the piece restarts from scratch + if not _is_transient(e) or attempt == PIECE_ATTEMPTS - 1: + raise + cloudlog.warning(f"retrying {label} after {type(e).__name__}: {e}") + if cancel.wait(PIECE_BACKOFF * (1 + attempt)): + raise DownloadCancelled("Download cancelled") from None + + class ModelManagerSP: """Manages model downloads and status reporting""" @@ -41,7 +131,7 @@ class ModelManagerSP: self.source_models: dict[str, list[custom.ModelManagerSP.ModelBundle]] = {} self.selected_bundle: custom.ModelManagerSP.ModelBundle = None self.active_bundle: custom.ModelManagerSP.ModelBundle = get_active_bundle(self.params, chestnut=self.chestnut_present) - self._chunk_size = 128 * 1000 # 128 KB chunks + self._block_size = 128 * 1000 # 128 KB network read blocks self._download_start_times: dict[str, float] = {} # Track start time per model self._download_ref: bytes | str | None = None @@ -56,7 +146,6 @@ class ModelManagerSP: self._download_ref = None def _sync_artifact_progress(self, source_artifact) -> None: - """Mirror download progress to all artifacts sharing the same filename in the selected bundle.""" if not self.selected_bundle: return for model in self.selected_bundle.models: @@ -67,7 +156,6 @@ class ModelManagerSP: artifact.downloadProgress.eta = source_artifact.downloadProgress.eta def _calculate_eta(self, filename: str, progress: float) -> int: - """Calculate ETA based on elapsed time and current progress""" if filename not in self._download_start_times or progress <= 0: return 60 # Default ETA for new downloads @@ -75,39 +163,58 @@ class ModelManagerSP: if elapsed_time <= 0: return 60 - # If we're at X% after Y seconds, we can estimate total time as (Y / X) * 100 total_estimated_time = (elapsed_time / progress) * 100 eta = total_estimated_time - elapsed_time - return max(1, int(eta)) # Return at least 1 second if download is ongoing - async def _download_file(self, url: str, path: str, model) -> None: - """Downloads a file with progress tracking""" - self._download_start_times[model.fileName] = time.monotonic() + def _set_progress(self, artifact, status, progress: float, eta: int = 0) -> None: + artifact.downloadProgress.status = status + artifact.downloadProgress.progress = progress + artifact.downloadProgress.eta = eta + self._sync_artifact_progress(artifact) + self._report_status() - with requests.get(url, stream=True, timeout=DOWNLOAD_TIMEOUT) as response: # noqa: ASYNC210 - response.raise_for_status() - total_size = int(response.headers.get("content-length", 0)) - bytes_downloaded = 0 + def _publish_progress(self, artifact, done_bytes: int, total: int) -> None: + # held at 99 until the file passes its hash check + progress = min(99.0, done_bytes / total * 100) if total > 0 else 0.0 + eta = self._calculate_eta(artifact.fileName, progress) + self._set_progress(artifact, custom.ModelManagerSP.DownloadStatus.downloading, progress, eta) - with open(path, 'wb') as f: # noqa: ASYNC230 - for chunk in response.iter_content(chunk_size=self._chunk_size): # type: bytes - f.write(chunk) - bytes_downloaded += len(chunk) + async def _report_until_done(self, tasks: list[asyncio.Future], artifact, progress: _Progress, total: int) -> None: + pending = set(tasks) + while pending: + done, pending = await asyncio.wait(pending, timeout=REPORT_INTERVAL) + for task in done: + task.result() # re-raises a worker failure + if self._download_interrupted(): + raise DownloadCancelled("Download cancelled") + self._publish_progress(artifact, progress.snapshot(), total) - if self._download_interrupted(): - raise DownloadCancelled("Download cancelled") + async def _download_file(self, url: str, path: str, artifact) -> None: + self._download_start_times[artifact.fileName] = time.monotonic() + loop = asyncio.get_running_loop() - if total_size > 0: - progress = (bytes_downloaded / total_size) * 100 - model.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading - model.downloadProgress.progress = progress - model.downloadProgress.eta = self._calculate_eta(model.fileName, progress) - self._sync_artifact_progress(model) - self._report_status() + total, ranged = await loop.run_in_executor(None, _probe_size, url) + pieces: list[tuple[int, int | None]] = list(_piece_ranges(total, PIECE_SIZE)) if ranged else [(0, None)] + await loop.run_in_executor(None, _prepare_target, path, total) + progress = _Progress() + cancel = threading.Event() - # Clean up start time after download completes - del self._download_start_times[model.fileName] + with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_PIECES) as pool: + tasks = [loop.run_in_executor(pool, _fetch_piece, url, path, i, start, end, progress, cancel, self._block_size) + for i, (start, end) in enumerate(pieces)] + try: + await self._report_until_done(tasks, artifact, progress, total) + except BaseException as e: + cancel.set() + if isinstance(e, DownloadCancelled): + artifact.downloadProgress.canceling = True + self._set_progress(artifact, custom.ModelManagerSP.DownloadStatus.downloading, artifact.downloadProgress.progress) + for task in tasks: + task.cancel() # drops queued pieces; running ones see the event at their next block + await asyncio.gather(*tasks, return_exceptions=True) + raise + del self._download_start_times[artifact.fileName] async def _process_artifact(self, artifact, destination_path: str) -> None: if not artifact.downloadUri.uri: @@ -119,33 +226,30 @@ class ModelManagerSP: expected_hash = artifact.downloadUri.sha256 filename = artifact.fileName full_path = os.path.join(destination_path, filename) + status = custom.ModelManagerSP.DownloadStatus + artifact.downloadProgress.canceling = False try: + self._set_progress(artifact, status.verifying, 0) if await verify_file(full_path, expected_hash): - artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached - artifact.downloadProgress.progress = 100 - artifact.downloadProgress.eta = 0 - self._sync_artifact_progress(artifact) - self._report_status() + self._set_progress(artifact, status.cached, 100) return + self._set_progress(artifact, status.downloading, 0) await self._download_file(url, full_path, artifact) + self._set_progress(artifact, status.verifying, 99) if not await verify_file(full_path, expected_hash): raise ValueError(f"Hash validation failed for {filename}") - artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloaded - artifact.downloadProgress.progress = 100 - artifact.downloadProgress.eta = 0 - self._sync_artifact_progress(artifact) - self._report_status() + self._set_progress(artifact, status.downloaded, 100) except DownloadCancelled: self._download_start_times.pop(artifact.fileName, None) - artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed + artifact.downloadProgress.status = status.failed artifact.downloadProgress.eta = 0 self._sync_artifact_progress(artifact) if self.selected_bundle: - self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed + self.selected_bundle.status = status.failed self._report_status() raise @@ -153,11 +257,11 @@ class ModelManagerSP: cloudlog.error(f"Error downloading {filename}: {str(e)}") if os.path.isfile(full_path): # noqa: ASYNC240 os.remove(full_path) - artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed + artifact.downloadProgress.status = status.failed artifact.downloadProgress.eta = 0 self._sync_artifact_progress(artifact) if self.selected_bundle: - self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed + self.selected_bundle.status = status.failed self._report_status() self._download_start_times.pop(artifact.fileName, None) raise @@ -272,12 +376,7 @@ class ModelManagerSP: rk.keep_time() def clear_model_cache(self) -> None: - """ - Clears the model cache directory of all files except those in the active model bundle. - """ - - # Get list of files used by both slots' selected bundles (either may become - # the truly active bundle depending on hardware availability) + """ Clears the model cache directory of all files except those in the active model bundle.""" active_files = [] for source in ACTIVE_BUNDLE_KEYS: if selected_bundle := get_selected_bundle(self.params, source): @@ -285,7 +384,6 @@ class ModelManagerSP: if model.artifact.fileName: active_files.append(model.artifact.fileName) - # Remove all files except active ones model_dir = Paths.model_root() try: for filename in os.listdir(model_dir): diff --git a/openpilot/sunnypilot/models/tests/test_manager_download.py b/openpilot/sunnypilot/models/tests/test_manager_download.py index 0ae0273ec4..662a776e9b 100644 --- a/openpilot/sunnypilot/models/tests/test_manager_download.py +++ b/openpilot/sunnypilot/models/tests/test_manager_download.py @@ -6,9 +6,15 @@ See the LICENSE.md file in the root directory for more details. """ import asyncio +import contextlib +import dataclasses import hashlib import http.server +import math import os +import random +import re +import requests import tempfile import threading import time @@ -16,251 +22,188 @@ import unittest from typing import Any from unittest import mock -import requests - from openpilot.cereal import custom from openpilot.common.test import OpenpilotTestCase -from openpilot.selfdrive.test.helpers import http_server_context from openpilot.sunnypilot.models import manager as manager_module from openpilot.sunnypilot.models.fetcher import ModelFetcher, get_cached_bundles from openpilot.sunnypilot.models import helpers -from openpilot.sunnypilot.models.helpers import (get_active_bundle, get_active_source, get_selected_bundle, +from openpilot.sunnypilot.models.helpers import (ACTIVE_BUNDLE_KEYS, get_active_bundle, get_active_source, get_selected_bundle, resolve_bundle_by_ref, validate_active_bundles) -from openpilot.sunnypilot.models.manager import ModelManagerSP +from openpilot.sunnypilot.models.manager import DownloadCancelled, ModelManagerSP -WHOLE_BODY = b'Z' * 9000 +FILE_NAME = 'driving_test_tinygrad.pkl' +# fixed-seed random bytes: every piece differs, so a misplaced piece changes the file +WHOLE_BODY = random.Random(0).randbytes(10_240) +PIECE_SIZE = 1000 # bytes per test piece -> 11 pieces, the last one short +NUM_PIECES = math.ceil(len(WHOLE_BODY) / PIECE_SIZE) def sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() -class DownloadHandler(http.server.BaseHTTPRequestHandler): - """Serves the fixture bodies. Class attributes are reset per test.""" - request_paths: list[str] = [] - fail_paths: dict[str, int] = {} - stall_paths: set[str] = set() +def piece_range(index: int) -> tuple[int, int]: + start = index * PIECE_SIZE + return start, min(start + PIECE_SIZE, len(WHOLE_BODY)) - 1 + + +@dataclasses.dataclass +class ServerState: + request_ranges: list[tuple[int, int] | None] = dataclasses.field(default_factory=list) # None: no (honored) Range header + request_encodings: list[str | None] = dataclasses.field(default_factory=list) # Accept-Encoding of every request + fail_ranges: dict[tuple[int, int], int] = dataclasses.field(default_factory=dict) # range -> HTTP status, every time + fail_times: dict[tuple[int, int], int] = dataclasses.field(default_factory=dict) # range -> how many more requests 503 + short_once: set[tuple[int, int]] = dataclasses.field(default_factory=set) # ranges whose first answer is cut short + stall_ranges: set[tuple[int, int]] = dataclasses.field(default_factory=set) stall_event: threading.Event | None = None + stall_released_by_event: bool | None = None + support_ranges: bool = True + corrupt: bool = False + + +class DownloadHandler(http.server.BaseHTTPRequestHandler): + state = ServerState() def log_message(self, format: str, *args: Any) -> None: # noqa: A002 pass - def _body_for(self, path): - return WHOLE_BODY if path.endswith('.pkl') else None - def do_GET(self): - type(self).request_paths.append(self.path) + state = self.state + body = WHOLE_BODY + if state.corrupt: + # flip every bit of one mid-file byte: same size, so only the hash check can catch it + body = body[:5000] + bytes([body[5000] ^ 0xFF]) + body[5001:] - status = type(self).fail_paths.get(self.path) - if status: - self.send_response(status) - self.end_headers() - return + rng = None + match = re.fullmatch(r'bytes=(\d+)-(\d+)', self.headers.get('Range', '')) + if match and state.support_ranges: + rng = (int(match.group(1)), int(match.group(2))) + state.request_ranges.append(rng) + state.request_encodings.append(self.headers.get('Accept-Encoding')) - body = self._body_for(self.path) - if body is None: - self.send_response(404) - self.end_headers() - return + if rng is not None: + status = state.fail_ranges.get(rng) + if status is None and state.fail_times.get(rng, 0) > 0: + state.fail_times[rng] -= 1 + status = 503 + if status is not None: + self.send_response(status) + self.end_headers() + return - self.send_response(200) - self.send_header('Content-Length', str(len(body))) + if rng is None: + payload = body + self.send_response(200) + else: + start, end = rng + payload = body[start:end + 1] + self.send_response(206) + self.send_header('Content-Range', f'bytes {start}-{end}/{len(body)}') + if rng in state.short_once: + state.short_once.discard(rng) + payload = payload[:100] + self.send_header('Content-Length', str(len(payload))) self.end_headers() - if self.path in type(self).stall_paths: - # write a little, then wait so the test can cancel mid-transfer - self.wfile.write(body[:100]) + if rng in state.stall_ranges: + # write a little, then hold the connection until the test releases it + self.wfile.write(payload[:100]) self.wfile.flush() - if type(self).stall_event is not None: - type(self).stall_event.wait(timeout=5) - self.wfile.write(body[100:]) + if state.stall_event is not None: + state.stall_released_by_event = state.stall_event.wait(timeout=5) + self.wfile.write(payload[100:]) else: - self.wfile.write(body) + self.wfile.write(payload) + + +@contextlib.contextmanager +def threaded_server(handler): + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), handler) + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + yield f'http://127.0.0.1:{server.server_port}' + finally: + server.shutdown() + server.server_close() + thread.join() class ManagerDownloadTestBase(OpenpilotTestCase): def setUp(self): super().setUp() - DownloadHandler.request_paths = [] - DownloadHandler.fail_paths = {} - DownloadHandler.stall_paths = set() - DownloadHandler.stall_event = None + self.server = DownloadHandler.state = ServerState() + self.base_url = self.enterContext(threaded_server(DownloadHandler)) self._tmp = tempfile.TemporaryDirectory() self.addCleanup(self._tmp.cleanup) self.dest = self._tmp.name + for name, value in (('PIECE_SIZE', PIECE_SIZE), ('REPORT_INTERVAL', 0.05), ('PIECE_BACKOFF', 0.01)): + self.enterContext(mock.patch.object(manager_module, name, value)) + self.reported: list[float] = [] + self.reported_statuses: list[int] = [] self.manager = ModelManagerSP.__new__(ModelManagerSP) - self.manager.params = mock.MagicMock() - self.manager.params.get.return_value = b'0' # not cancelled + self.params = self.manager.params = mock.MagicMock() + self.params.get.return_value = b'0' # not cancelled self.manager._download_ref = b'0' - self.manager.pm = mock.MagicMock() - self.manager.pm.send.side_effect = self._record_progress + self.pm = self.manager.pm = mock.MagicMock() + self.pm.send.side_effect = self._record_progress self.manager.selected_bundle = None self.manager.active_bundle = None self.manager.available_models = [] self.manager.chestnut_present = False - self.manager._chunk_size = 1024 + self.manager._block_size = 256 self.manager._download_start_times = {} def _record_progress(self, *args) -> None: - """Runs on every real _report_status send.""" artifact = getattr(self, 'artifact', None) if artifact is not None: self.reported.append(float(artifact.downloadProgress.progress)) + status = artifact.downloadProgress.status + self.reported_statuses.append(getattr(status, 'raw', status)) # .raw: _DynamicEnum equals its int but does not hash like it def make_artifact(self): bundle = custom.ModelManagerSP.ModelBundle.new_message() bundle.init('models', 1) artifact = bundle.models[0].artifact - artifact.fileName = 'driving_test_tinygrad.pkl' - artifact.downloadUri.uri = self.base_url + '/driving_test_tinygrad.pkl' + artifact.fileName = FILE_NAME + artifact.downloadUri.uri = self.base_url + '/' + FILE_NAME artifact.downloadUri.sha256 = sha256(WHOLE_BODY) self._bundle = bundle self.artifact = artifact return artifact -class TestManagerDownload(ManagerDownloadTestBase): - """Exercises the real _download_file against a local server.""" + @property + def path(self) -> str: + return os.path.join(self.dest, FILE_NAME) - def run_with_server(self, fn): - with http_server_context(handler=DownloadHandler) as (host, port): - self.base_url = f'http://{host}:{port}' - return fn() + def piece_requests(self) -> list[tuple[int, int]]: + return [r for r in self.server.request_ranges if r is not None and r != (0, 0)] - def test_download_file_writes_exact_bytes(self): - def body(): - artifact = self.make_artifact() - path = os.path.join(self.dest, artifact.fileName) - asyncio.run(self.manager._download_file(artifact.downloadUri.uri, path, artifact)) - with open(path, 'rb') as f: - written = f.read() - assert written == WHOLE_BODY - assert sha256(written) == artifact.downloadUri.sha256 - assert artifact.fileName not in self.manager._download_start_times - self.run_with_server(body) + def stall(self, index: int) -> threading.Event: + self.server.stall_ranges = {piece_range(index)} + self.server.stall_event = threading.Event() + return self.server.stall_event - def test_progress_is_monotonic_and_bounded(self): - def body(): - artifact = self.make_artifact() - base_path = os.path.join(self.dest, artifact.fileName) - asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) + def download_file(self): + artifact = self.make_artifact() + asyncio.run(self.manager._download_file(artifact.downloadUri.uri, self.path, artifact)) + return artifact - assert self.reported, "expected progress reports" - for a, b in zip(self.reported, self.reported[1:], strict=False): - assert b >= a, f"progress went backwards: {a} -> {b}" - self.run_with_server(body) + def process_artifact(self): + artifact = self.make_artifact() + asyncio.run(self.manager._process_artifact(artifact, self.dest)) + return artifact - def test_http_error_propagates(self): - def body(): - artifact = self.make_artifact() - base_path = os.path.join(self.dest, artifact.fileName) - DownloadHandler.fail_paths = {'/' + artifact.fileName: 404} + def download_bundle(self, source: str): + asyncio.run(self.manager._download_bundle(self._bundle, self.dest, source)) - with self.assertRaises(requests.exceptions.HTTPError): - asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) - assert not os.path.isfile(base_path) - self.run_with_server(body) - - def test_cancellation_mid_transfer(self): - """Cancellation is checked inside the byte loop; it must still fire after the port.""" - def body(): - artifact = self.make_artifact() - base_path = os.path.join(self.dest, artifact.fileName) - self.manager.params.get.return_value = None # cancelled - - with self.assertRaises(Exception) as ctx: - asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) - assert 'cancelled' in str(ctx.exception).lower() - self.run_with_server(body) - - def test_repeat_downloads_are_stable(self): - """Back-to-back runs must produce identical bytes and leak no start-time state.""" - def body(): - for _ in range(2): - artifact = self.make_artifact() - base_path = os.path.join(self.dest, artifact.fileName) - asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) - with open(base_path, 'rb') as f: - assert f.read() == WHOLE_BODY - assert self.manager._download_start_times == {} - self.run_with_server(body) - - def test_download_ref_present_keeps_download_alive(self): - """A pending download request (DownloadRef set) must not be cancelled mid-transfer.""" - def body(): - artifact = self.make_artifact() - base_path = os.path.join(self.dest, artifact.fileName) - self.manager.params.get.side_effect = lambda key: b"ref" if key == "ModelManager_DownloadRef" else None - self.manager._download_ref = b"ref" - asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) - assert os.path.isfile(base_path) - self.run_with_server(body) - - def test_cancellation_via_download_ref(self): - """Removing DownloadRef mid-transfer cancels the download.""" - def body(): - artifact = self.make_artifact() - base_path = os.path.join(self.dest, artifact.fileName) - checks = {"n": 0} - - def get(key): - if key == "ModelManager_DownloadRef": - checks["n"] += 1 - return b"ref" if checks["n"] <= 2 else None - return b"0" - - self.manager.params.get.side_effect = get - self.manager._download_ref = b"ref" - with self.assertRaises(Exception) as ctx: - asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) - assert 'cancelled' in str(ctx.exception).lower() - self.run_with_server(body) - - def test_replaced_download_ref_queues_instead_of_cancelling(self): - """Selecting another model mid-transfer lets the running download finish.""" - def body(): - artifact = self.make_artifact() - base_path = os.path.join(self.dest, artifact.fileName) - self.manager.params.get.side_effect = lambda key: b"other-ref" if key == "ModelManager_DownloadRef" else None - self.manager._download_ref = b"ref" - asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact)) - assert os.path.isfile(base_path) - self.run_with_server(body) - - def test_replaced_download_ref_is_kept(self): - """A selection made during a download must survive that download's cleanup.""" - self.manager.params.get.return_value = b"new-ref" - self.manager._download_ref = b"old-ref" - self.manager._release_download_ref() - self.manager.params.remove.assert_not_called() - - def test_own_download_ref_is_released(self): - self.manager.params.get.return_value = b"ref" - self.manager._download_ref = b"ref" - self.manager._release_download_ref() - self.manager.params.remove.assert_called_once_with("ModelManager_DownloadRef") - - def test_cached_bundle_cancel_skips_slot_write(self): - """A cancel must stop an already-on-disk bundle before it is applied to the slot.""" - def body(): - artifact = self.make_artifact() - base_path = os.path.join(self.dest, artifact.fileName) - with open(base_path, 'wb') as f: - f.write(WHOLE_BODY) - self._bundle.ref = "test-ref" - params, store = self._make_params_with_store() - store["ModelManager_DownloadRef"] = None # removed -> cancelled - self.manager.params = params - self.manager._download_ref = b"ref" - with self.assertRaises(Exception) as ctx: - asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "qcom")) - assert 'cancelled' in str(ctx.exception).lower() - assert "ModelManager_ActiveBundle" not in store - assert os.path.isfile(base_path), "cancel must not delete the cached model" - self.run_with_server(body) + def read_path(self) -> bytes: + with open(self.path, 'rb') as f: + return f.read() def _make_params_with_store(self): params = mock.MagicMock() @@ -276,84 +219,156 @@ class TestManagerDownload(ManagerDownloadTestBase): params.put.side_effect = put return params, store - def test_download_writes_qcom_slot(self): - """A download resolved to the qcom source writes the qcom active bundle slot only.""" - def body(): - artifact = self.make_artifact() - self._bundle.ref = "test-ref" - self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION - params, store = self._make_params_with_store() - self.manager.params = params - asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "qcom")) - assert "ModelManager_ActiveBundle" in store, "qcom download must write the qcom slot" - assert "ModelManager_ActiveBundleChestnut" not in store, "qcom download must not touch the chestnut slot" - assert self.manager.selected_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded - assert self.manager.active_bundle is not None and self.manager.active_bundle.ref == "test-ref" - assert self.manager.active_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded - assert os.path.isfile(os.path.join(self.dest, artifact.fileName)) - self.run_with_server(body) +class TestManagerDownload(ManagerDownloadTestBase): + def test_download(self): + artifact = self.download_file() + assert self.read_path() == WHOLE_BODY + assert sorted(self.piece_requests()) == [piece_range(i) for i in range(NUM_PIECES)], "pieces must tile the file once" + assert self.server.request_encodings == ['identity'] * (NUM_PIECES + 1), "ranges count encoded bytes, so ask for none" + assert self.reported and self.reported == sorted(self.reported), "progress only rises" + assert max(self.reported) <= 99.0, "progress holds below 100 until the hash check" + assert artifact.fileName not in self.manager._download_start_times - def test_download_writes_chestnut_slot(self): - """A download resolved to the chestnut source writes the chestnut active bundle slot only.""" - def body(): - self.make_artifact() - self._bundle.ref = "big-ref" - self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION - params, store = self._make_params_with_store() - self.manager.params = params - asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "chestnut")) + def test_pieces_download_in_parallel(self): + stall_event = self.stall(0) + others = {piece_range(i) for i in range(1, NUM_PIECES)} - assert "ModelManager_ActiveBundleChestnut" in store, "chestnut download must write the chestnut slot" - assert "ModelManager_ActiveBundle" not in store, "chestnut download must not touch the qcom slot" - assert self.manager.selected_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded - self.run_with_server(body) + def release_when_others_requested(): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if others <= set(self.piece_requests()): + stall_event.set() + return + time.sleep(0.01) + + threading.Thread(target=release_when_others_requested).start() + self.download_file() + assert self.server.stall_released_by_event is True, "other pieces did not download while one was stalled" + + def test_fallback_when_server_ignores_ranges(self): + self.server.support_ranges = False + self.download_file() + assert self.read_path() == WHOLE_BODY + assert self.server.request_ranges == [None, None], "expected the probe plus one whole-body request" + assert self.server.request_encodings == ['identity', 'identity'] + + def test_transient_failures_are_retried(self): + self.server.fail_times = {piece_range(2): 1} + self.server.short_once = {piece_range(4)} + self.download_file() + assert self.read_path() == WHOLE_BODY + assert self.piece_requests().count(piece_range(2)) == 2 + assert self.piece_requests().count(piece_range(4)) == 2 + + def test_permanent_failure_is_not_retried(self): + self.server.fail_ranges = {piece_range(1): 404} + with self.assertRaises(requests.exceptions.HTTPError): + self.download_file() + assert self.piece_requests().count(piece_range(1)) == 1 + + def test_removing_download_ref_cancels(self): + stall_event = self.stall(3) + refs = iter([b"ref"]) + self.params.get.side_effect = lambda key: next(refs, None) # removed after the first poll + self.manager._download_ref = b"ref" + + def release_once_canceling(*args): + # the "canceling" report comes after the cancel event is set, so the piece can't finish first + if self.artifact.downloadProgress.canceling: + stall_event.set() + self.pm.send.side_effect = release_once_canceling + with self.assertRaises(DownloadCancelled): + self.download_file() + assert self.server.stall_released_by_event is True, "the cancel must have caught the piece mid-transfer" + end = piece_range(3)[1] + 1 + assert self.read_path()[end - 100:end] == bytes(100), "the cancelled piece must stop before its last bytes land" + + def test_replaced_download_ref_queues_instead_of_canceling(self): + self.params.get.return_value = b"other-ref" + self.manager._download_ref = b"ref" + self.download_file() + assert self.read_path() == WHOLE_BODY -class TestManagerImports(OpenpilotTestCase): - """Catches undeclared dependencies. aiohttp lived only in the AGNOS venv; 19.6 dropped - it and models_manager died on device while CI stayed green.""" +class TestProcessArtifact(ManagerDownloadTestBase): + def test_download_writes_only_its_slot(self): + ds = custom.ModelManagerSP.DownloadStatus + for source, other in (("qcom", "chestnut"), ("chestnut", "qcom")): # bernoulli + with self.subTest(source=source): + self.make_artifact() + self._bundle.ref = f"{source}-ref" + self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION + self.manager.params, store = self._make_params_with_store() + self.download_bundle(source) + assert ACTIVE_BUNDLE_KEYS[source] in store and ACTIVE_BUNDLE_KEYS[other] not in store + assert self.manager.selected_bundle.status == ds.downloaded + assert self.artifact.downloadProgress.progress == 100 and self.artifact.downloadProgress.eta == 0 + assert self.read_path() == WHOLE_BODY + os.remove(self.path) - def test_manager_imports(self): - assert manager_module.ModelManagerSP is not None + def test_hash_mismatch_discards_file(self): + self.server.corrupt = True + with self.assertRaises(ValueError): + self.process_artifact() + assert not os.path.isfile(self.path) + assert self.artifact.downloadProgress.status == custom.ModelManagerSP.DownloadStatus.failed - def test_no_undeclared_http_client(self): - with open(manager_module.__file__) as f: - src = f.read() - assert 'import aiohttp' not in src, "aiohttp is not available on AGNOS 19.6; use requests" + def test_probe_failure_moves_past_verifying(self): + self.server.fail_ranges = {(0, 0): 404} # the size probe + with self.assertRaises(requests.exceptions.HTTPError): + self.process_artifact() + ds = custom.ModelManagerSP.DownloadStatus + assert self.reported_statuses == [ds.verifying, ds.downloading, ds.failed] - def test_download_timeout_is_explicit(self): - connect, read = manager_module.DOWNLOAD_TIMEOUT - assert connect > 0 and read > 0, "requests defaults to no timeout; downloads would hang forever" + def test_cached_file_skips_network(self): + with open(self.path, 'wb') as f: + f.write(WHOLE_BODY) + self.process_artifact() + assert self.server.request_ranges == [], "cached file must not hit the network" + ds = custom.ModelManagerSP.DownloadStatus + assert self.reported_statuses == [ds.verifying, ds.cached] + + def test_cached_bundle_cancel_skips_slot_write(self): + with open(self.path, 'wb') as f: + f.write(WHOLE_BODY) + self.make_artifact() + self.manager.params, store = self._make_params_with_store() + store["ModelManager_DownloadRef"] = None # removed -> cancelled + self.manager._download_ref = b"ref" + with self.assertRaises(DownloadCancelled): + self.download_bundle("qcom") + assert ACTIVE_BUNDLE_KEYS["qcom"] not in store + assert os.path.isfile(self.path), "cancel must not delete a cached model" + + def test_download_ref_is_released_only_if_unchanged(self): + for current, removed in ((b"new-ref", False), (b"ref", True)): + self.params = self.manager.params = mock.MagicMock() + self.params.get.return_value = current + self.manager._download_ref = b"ref" + self.manager._release_download_ref() + assert self.params.remove.called == removed + + +def raw_bundle(ref: str, runner: int | None = None) -> dict: + bundle = custom.ModelManagerSP.ModelBundle.new_message() + bundle.ref = ref + bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION + if runner is not None: + bundle.runner = runner + return bundle.to_dict() class TestResolveBundleByRef(OpenpilotTestCase): - """A ref resolves to (bundle, source) across both hardware manifests. Refs are - unique per manifest and never overlap across sources, so a ref maps to exactly - one slot. Shared by the manager's download flow and the settings UI.""" - - @staticmethod - def _bundle(ref: str): - bundle = custom.ModelManagerSP.ModelBundle.new_message() - bundle.ref = ref - return bundle - - def test_qcom_ref_resolves_to_qcom_slot(self): - small = self._bundle("small") - assert resolve_bundle_by_ref("small", {"qcom": [small], "chestnut": []}) == (small, "qcom") - - def test_chestnut_ref_resolves_to_chestnut_slot(self): - big = self._bundle("big") - assert resolve_bundle_by_ref("big", {"qcom": [], "chestnut": [big]}) == (big, "chestnut") - - def test_unknown_ref_returns_none(self): - source_bundles = {"qcom": [self._bundle("small")], "chestnut": []} - assert resolve_bundle_by_ref("nope", source_bundles) is None + def test_ref_resolves_to_its_slot(self): + small, big = (custom.ModelManagerSP.ModelBundle(**raw_bundle(ref)) for ref in ("small", "big")) + catalog = {"qcom": [small], "chestnut": [big]} + assert resolve_bundle_by_ref("small", catalog) == (small, "qcom") + assert resolve_bundle_by_ref("big", catalog) == (big, "chestnut") + assert resolve_bundle_by_ref("nope", catalog) is None def manifest_bundle(short_name: str, ref: str, index: int = 0, is_big: bool = False) -> dict: - """Minimal manifest bundle dict, version-compatible (no chunks to avoid disk side effects). - Big (chestnut) bundles carry `is_big: true` in the manifest JSON.""" return { "index": index, "short_name": short_name, @@ -374,309 +389,127 @@ def manifest_bundle(short_name: str, ref: str, index: int = 0, is_big: bool = Fa } -def fresh_sync_time() -> int: - return int(time.monotonic() * 1e9) +def manifest_params(qcom_manifest, chestnut_manifest=None): + values = { + "ModelManager_ModelsCache": qcom_manifest, + "ModelManager_ModelsCache_Chestnut": chestnut_manifest or {"bundles": [manifest_bundle("big", "bbb", is_big=True)]}, + "ModelManager_LastSyncTime": int(time.monotonic() * 1e9), + "ModelManager_LastSyncTime_Chestnut": int(time.monotonic() * 1e9), + } + params = mock.MagicMock() + params.get.side_effect = values.get + return params -class TestModelFetcherSources(OpenpilotTestCase): - """Both manifests are always maintained: get_bundles_for_source exposes either - source by name, and active_source picks which one matches the attached hardware.""" - - def _make_params(self, qcom_manifest, chestnut_manifest): - params = mock.MagicMock() - - def get(key): - if key == "ModelManager_ModelsCache": - return qcom_manifest - if key == "ModelManager_ModelsCache_Chestnut": - return chestnut_manifest - if key in ("ModelManager_LastSyncTime", "ModelManager_LastSyncTime_Chestnut"): - return fresh_sync_time() - return None - - params.get.side_effect = get - return params +class TestModelFetcher(OpenpilotTestCase): + def refetch(self, fetcher, *bundles): + fetched = fetcher.model_parser.parse_models({"bundles": list(bundles)}) + return mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched) def test_active_source_follows_chestnut_presence(self): assert ModelFetcher.active_source(False) == "qcom" assert ModelFetcher.active_source(True) == "chestnut" - def test_get_bundles_for_source_returns_each_source(self): - params = self._make_params({"bundles": [manifest_bundle("small", "aaa")]}, - {"bundles": [manifest_bundle("big", "bbb", is_big=True)]}) - fetcher = ModelFetcher(params) - assert [bundle.ref for bundle in fetcher.get_bundles_for_source("qcom")] == ["aaa"] - assert [bundle.ref for bundle in fetcher.get_bundles_for_source("chestnut")] == ["bbb"] - - def test_get_bundles_for_source_unknown(self): + def test_unknown_source_has_no_bundles(self): assert ModelFetcher(mock.MagicMock()).get_bundles_for_source("bogus") == [] - - def test_get_cached_bundles_parses_source(self): - params = self._make_params({"bundles": [manifest_bundle("small", "aaa")]}, - {"bundles": [manifest_bundle("big", "bbb", is_big=True)]}) - qcom_bundles = get_cached_bundles(params, "qcom") - chestnut_bundles = get_cached_bundles(params, "chestnut") - assert [b.ref for b in qcom_bundles] == ["aaa"] - assert [b.ref for b in chestnut_bundles] == ["bbb"] - assert qcom_bundles[0].displayName == "SMALL" - - def test_get_cached_bundles_empty_when_missing(self): - params = mock.MagicMock() - params.get.return_value = None - assert get_cached_bundles(params, "qcom") == [] - assert get_cached_bundles(params, "chestnut") == [] - - def test_get_cached_bundles_unknown_source(self): assert get_cached_bundles(mock.MagicMock(), "bogus") == [] + def test_get_cached_bundles_reads_its_source(self): + params = manifest_params({"bundles": [manifest_bundle("small", "aaa")]}) + assert [b.ref for b in get_cached_bundles(params, "qcom")] == ["aaa"] + assert [b.ref for b in get_cached_bundles(params, "chestnut")] == ["bbb"] + def test_active_json_has_both_urls(self): params = mock.MagicMock() ModelFetcher(params) - active_json_calls = [call for call in params.put.call_args_list if call.args[0] == "ModelManager_ActiveJson"] - assert active_json_calls, "expected ModelManager_ActiveJson to be written" - assert active_json_calls[-1].args[1] == { - "qcom": ModelFetcher.MODEL_URL, - "chestnut": ModelFetcher.MODEL_URL_CHESTNUT, - } - - - -class TestSourceCacheIntegrity(OpenpilotTestCase): - """Each source's cached manifest must contain only that source's models; the - `is_big` flag in the JSON marks the big (chestnut) models. A mismatched cache is - legacy data from before the per-source split (the active manifest was cached - under the unsuffixed key regardless of hardware) and is refetched. This - replaces the old one-time bundle migration.""" - - def _make_params(self, qcom_manifest, chestnut_manifest): - params = mock.MagicMock() - - def get(key): - if key == "ModelManager_ModelsCache": - return qcom_manifest - if key == "ModelManager_ModelsCache_Chestnut": - return chestnut_manifest - if key in ("ModelManager_LastSyncTime", "ModelManager_LastSyncTime_Chestnut"): - return fresh_sync_time() - return None - - params.get.side_effect = get - return params - - def _fetched(self, *bundles): - return ModelFetcher(mock.MagicMock()).model_parser.parse_models({"bundles": list(bundles)}) - - def test_qcom_cache_with_big_models_is_refetched(self): - """Legacy: the unsuffixed cache holds the big manifest. is_big confirms it is - the wrong set for qcom, so a fresh fetch replaces it.""" - params = self._make_params({"bundles": [manifest_bundle("big", "bbb", is_big=True)]}, - {"bundles": [manifest_bundle("big2", "ccc", is_big=True)]}) - fetcher = ModelFetcher(params) - fetched = self._fetched(manifest_bundle("small", "aaa")) - with mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched): - bundles = fetcher.get_bundles_for_source("qcom") - assert [bundle.ref for bundle in bundles] == ["aaa"] - - def test_chestnut_cache_without_big_models_is_refetched(self): - params = self._make_params({"bundles": [manifest_bundle("small", "aaa")]}, - {"bundles": [manifest_bundle("big2", "ccc")]}) - fetcher = ModelFetcher(params) - fetched = self._fetched(manifest_bundle("big", "bbb", is_big=True)) - with mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched): - bundles = fetcher.get_bundles_for_source("chestnut") - assert [bundle.ref for bundle in bundles] == ["bbb"] + params.put.assert_any_call("ModelManager_ActiveJson", {"qcom": ModelFetcher.MODEL_URL, "chestnut": ModelFetcher.MODEL_URL_CHESTNUT}, block=True) def test_matching_caches_are_used_without_fetch(self): - params = self._make_params({"bundles": [manifest_bundle("small", "aaa")]}, - {"bundles": [manifest_bundle("big", "bbb", is_big=True)]}) - fetcher = ModelFetcher(params) + fetcher = ModelFetcher(manifest_params({"bundles": [manifest_bundle("small", "aaa")]})) with mock.patch.object(fetcher, "_fetch_and_cache_models", side_effect=AssertionError("cache should be used")): assert [bundle.ref for bundle in fetcher.get_bundles_for_source("qcom")] == ["aaa"] assert [bundle.ref for bundle in fetcher.get_bundles_for_source("chestnut")] == ["bbb"] - def test_stale_version_cache_is_refetched(self): - """A source-matching cache whose bundles are all filtered by the selector version - check parses to zero valid bundles; it is stale (e.g. an old manifest) and must be - refetched instead of silently returning an empty list forever.""" - stale = manifest_bundle("small", "aaa") - stale["minimum_selector_version"] = "16" - params = self._make_params({"bundles": [stale]}, - {"bundles": [manifest_bundle("big", "bbb", is_big=True)]}) - fetcher = ModelFetcher(params) - fetched = self._fetched(manifest_bundle("small2", "ddd")) - with mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched) as fetch: - bundles = fetcher.get_bundles_for_source("qcom") - fetch.assert_called_once_with("qcom") - assert [bundle.ref for bundle in bundles] == ["ddd"] + def test_unusable_cache_is_refetched(self): + stale = {**manifest_bundle("small", "aaa"), "minimum_selector_version": "16"} # parses to zero valid bundles + corrupt = {"short_name": "broken"} # missing required fields + for cached in (stale, corrupt): + with self.subTest(cached=cached): + fetcher = ModelFetcher(manifest_params({"bundles": [cached]})) + with self.refetch(fetcher, manifest_bundle("small2", "ddd")) as fetch: + assert [bundle.ref for bundle in fetcher.get_bundles_for_source("qcom")] == ["ddd"] + fetch.assert_called_once_with("qcom") - def test_mismatched_refetch_happens_once(self): - """If the fresh manifest still fails the source check, the URL is authoritative: - trust it instead of refetching at 1 Hz forever.""" - params = self._make_params({"bundles": [manifest_bundle("big", "bbb", is_big=True)]}, - {"bundles": [manifest_bundle("big2", "ccc", is_big=True)]}) - fetcher = ModelFetcher(params) - fetched = self._fetched(manifest_bundle("big", "bbb", is_big=True)) - with mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched) as fetch: + def test_mismatched_cache_is_refetched_once(self): + fetcher = ModelFetcher(manifest_params({"bundles": [manifest_bundle("big", "bbb", is_big=True)]})) + with self.refetch(fetcher, manifest_bundle("big", "bbb", is_big=True)) as fetch: first = fetcher.get_bundles_for_source("qcom") second = fetcher.get_bundles_for_source("qcom") fetch.assert_called_once_with("qcom") - assert [bundle.ref for bundle in first] == ["bbb"] - assert [bundle.ref for bundle in second] == ["bbb"] - - def test_corrupt_cache_is_refetched(self): - """A cache that fails to parse (e.g. truncated/foreign JSON) must trigger a - refetch instead of raising every loop and never recovering.""" - corrupt = {"bundles": [{"short_name": "broken"}]} # missing required fields - params = self._make_params(corrupt, {"bundles": [manifest_bundle("big", "bbb", is_big=True)]}) - fetcher = ModelFetcher(params) - fetched = self._fetched(manifest_bundle("small", "aaa")) - with mock.patch.object(fetcher, "_fetch_and_cache_models", return_value=fetched) as fetch: - bundles = fetcher.get_bundles_for_source("qcom") - fetch.assert_called_once_with("qcom") - assert [bundle.ref for bundle in bundles] == ["aaa"] + assert [bundle.ref for bundle in first] == [bundle.ref for bundle in second] == ["bbb"] -class TestActiveBundleValidation(OpenpilotTestCase): - """Validation is per-slot: a failed fetch (empty bundle list) must not reset a slot, - and resetting one slot must not stomp the runner cache derived from the other.""" +def ref_of(bundle) -> str | None: + return None if bundle is None else bundle.ref + +def slot_params(qcom=None, chestnut=None): + params = mock.MagicMock() + params.get.side_effect = {"ModelManager_ActiveBundle": qcom, "ModelManager_ActiveBundleChestnut": chestnut}.get + return params + + +class TestActiveBundle(OpenpilotTestCase): def setUp(self): super().setUp() helpers._LAST_VALIDATED_RAW.clear() - @staticmethod - def _raw_bundle(ref: str, runner: int | None = None) -> dict: - bundle = custom.ModelManagerSP.ModelBundle.new_message() - bundle.ref = ref - bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION - if runner is not None: - bundle.runner = runner - return bundle.to_dict() + def test_active_source(self): + for gpu, source in ((False, "qcom"), (True, "chestnut")): + with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=gpu): + assert get_active_source() == source + for state, source in ( + ((True, False, False, True), "chestnut"), # offroad + ((True, False, True, False), "chestnut"), # onroad, big model loading + ((True, True, False, False), "chestnut"), # onroad, big model running + ((True, False, False, False), "qcom"), # onroad, big model idle + ((True, None, False, False), "qcom"), # onroad, big model state not known yet + ((False, False, False, True), "qcom"), # offroad, no GPU + ): + chestnut, active, loading, offroad = state + assert get_active_source(chestnut=chestnut, chestnut_active=active, chestnut_loading=loading, offroad=offroad) == source, state - def _params(self, qcom=None, chestnut=None): - params = mock.MagicMock() - - def get(key, *args, **kwargs): - return {"ModelManager_ActiveBundle": qcom, "ModelManager_ActiveBundleChestnut": chestnut}.get(key) - - params.get.side_effect = get - return params + def test_active_bundle_is_the_active_slot(self): + params = slot_params(qcom=raw_bundle("small"), chestnut=raw_bundle("big")) + assert ref_of(get_selected_bundle(params, "qcom")) == "small" + assert ref_of(get_selected_bundle(params, "chestnut")) == "big" + for gpu, ref in ((False, "small"), (True, "big")): + with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=gpu): + assert ref_of(get_active_bundle(params)) == ref + with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=True): + assert get_active_bundle(slot_params(qcom=raw_bundle("small"))) is None, \ + "an empty chestnut slot must not fall back to the qcom pick" def test_empty_catalog_does_not_reset_slot(self): - params = self._params(qcom=self._raw_bundle("small")) + params = slot_params(qcom=raw_bundle("small")) with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=False): validate_active_bundles(params, {"qcom": [], "chestnut": []}) params.remove.assert_not_called() def test_reset_recomputes_runner_from_surviving_slot(self): tinygrad = int(custom.ModelManagerSP.Runner.tinygrad) - big_raw = self._raw_bundle("big", runner=tinygrad) - params = self._params(qcom=self._raw_bundle("gone"), chestnut=big_raw) - catalog = {"qcom": [custom.ModelManagerSP.ModelBundle(**self._raw_bundle("other"))], + big_raw = raw_bundle("big", runner=tinygrad) + params = slot_params(qcom=raw_bundle("gone"), chestnut=big_raw) + catalog = {"qcom": [custom.ModelManagerSP.ModelBundle(**raw_bundle("other"))], "chestnut": [custom.ModelManagerSP.ModelBundle(**big_raw)]} with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=True): validate_active_bundles(params, catalog) params.remove.assert_called_once_with("ModelManager_ActiveBundle") - runner_puts = [call for call in params.put.call_args_list if call.args[0] == "ModelRunnerTypeCache"] - assert [call.args[1] for call in runner_puts] == [tinygrad] - - -class TestActiveBundleSelection(OpenpilotTestCase): - """The effective active bundle is the active source's slot: chestnut when a GPU is - present, qcom otherwise. An empty active slot means the hardware default (stock - runner), never the other slot's pick - modeld_v2 requires a real bundle.""" - - @staticmethod - def _raw_bundle(ref: str) -> dict: - bundle = custom.ModelManagerSP.ModelBundle.new_message() - bundle.ref = ref - bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION - return bundle.to_dict() - - def _params(self, qcom=None, chestnut=None): - params = mock.MagicMock() - - def get(key, *args, **kwargs): - if key == "ModelManager_ActiveBundle": - return qcom - if key == "ModelManager_ActiveBundleChestnut": - return chestnut - return None - - params.get.side_effect = get - return params - - def test_selected_bundle_is_per_slot(self): - params = self._params(qcom=self._raw_bundle("small"), chestnut=self._raw_bundle("big")) - assert get_selected_bundle(params, "qcom").ref == "small" - assert get_selected_bundle(params, "chestnut").ref == "big" - - def test_no_gpu_uses_qcom_slot(self): - params = self._params(qcom=self._raw_bundle("small"), chestnut=self._raw_bundle("big")) - with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=False): - assert get_active_bundle(params).ref == "small" - - def test_gpu_uses_chestnut_slot(self): - params = self._params(qcom=self._raw_bundle("small"), chestnut=self._raw_bundle("big")) - with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=True): - assert get_active_bundle(params).ref == "big" - - def test_gpu_without_big_selection_is_hardware_default(self): - params = self._params(qcom=self._raw_bundle("small"), chestnut=None) - with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=True): - assert get_active_bundle(params) is None - - -class TestEffectiveSource(OpenpilotTestCase): - """One gate decides the active source. With no flags it is runtime truth (GPU - attached); display callers (mici) pass the ui_state flags, which additionally - require the big model to be loading, active, or the device offroad. The active - bundle is simply the selected bundle of that source.""" - - @staticmethod - def _raw_bundle(ref: str) -> dict: - bundle = custom.ModelManagerSP.ModelBundle.new_message() - bundle.ref = ref - bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION - return bundle.to_dict() - - def test_runtime_no_gpu(self): - with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=False): - assert get_active_source() == "qcom" - - def test_runtime_gpu_present(self): - with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=True): - assert get_active_source() == "chestnut" - - def test_display_offroad_gpu_present_shows_big(self): - assert get_active_source(chestnut=True, chestnut_active=False, chestnut_loading=False, offroad=True) == "chestnut" - - def test_display_onroad_gpu_loading_shows_big(self): - assert get_active_source(chestnut=True, chestnut_active=False, chestnut_loading=True, offroad=False) == "chestnut" - - def test_display_onroad_gpu_active_shows_big(self): - assert get_active_source(chestnut=True, chestnut_active=True, chestnut_loading=False, offroad=False) == "chestnut" - - def test_display_onroad_gpu_idle_shows_small(self): - assert get_active_source(chestnut=True, chestnut_active=False, chestnut_loading=False, offroad=False) == "qcom" - - def test_display_active_none_is_idle(self): - assert get_active_source(chestnut=True, chestnut_active=None, chestnut_loading=False, offroad=False) == "qcom" - - def test_active_bundle_follows_source(self): - params = mock.MagicMock() - params.get.side_effect = lambda key: {"ModelManager_ActiveBundle": self._raw_bundle("small"), - "ModelManager_ActiveBundleChestnut": self._raw_bundle("big")}.get(key) - with mock.patch("openpilot.sunnypilot.models.helpers.chestnut_present", return_value=False): - assert get_active_bundle(params).ref == "small" - assert get_selected_bundle(params, get_active_source(chestnut=True, chestnut_active=False, - chestnut_loading=False, offroad=True)).ref == "big" + params.put.assert_called_once_with("ModelRunnerTypeCache", tinygrad, block=True) @unittest.skipUnless(os.environ.get('RUN_INTEGRATION_TESTS'), 'requires external network') class TestLiveModelManifest(OpenpilotTestCase): - """Every artifact URL in the published manifest must resolve.""" - def test_all_manifest_urls_available(self): from openpilot.sunnypilot.models.fetcher import ModelFetcher @@ -687,15 +520,13 @@ class TestLiveModelManifest(OpenpilotTestCase): for bundle in manifest.get('bundles', []): for model in bundle.get('models', []): artifact = model['artifact'] - urls = [artifact['download_uri']['url']] - for u in urls: - try: - r = session.head(u, timeout=15, allow_redirects=True) - if r.status_code != 200: - dead.append(f"{bundle.get('short_name')}: HTTP {r.status_code} {u}") - except requests.RequestException as e: - dead.append(f"{bundle.get('short_name')}: {type(e).__name__} {u}") - + url = artifact['download_uri']['url'] + try: + r = session.head(url, timeout=15, allow_redirects=True) + if r.status_code != 200: + dead.append(f"{bundle.get('short_name')}: HTTP {r.status_code} {url}") + except requests.RequestException as e: + dead.append(f"{bundle.get('short_name')}: {type(e).__name__} {url}") assert not dead, "unreachable model URLs:\n" + "\n".join(dead) diff --git a/openpilot/system/ui/sunnypilot/widgets/download_status.py b/openpilot/system/ui/sunnypilot/widgets/download_status.py index c85814d0eb..e0f45186bc 100644 --- a/openpilot/system/ui/sunnypilot/widgets/download_status.py +++ b/openpilot/system/ui/sunnypilot/widgets/download_status.py @@ -57,7 +57,7 @@ class DownloadStatusAction(ItemAction): self.icon: str | None = None self.icon_color: rl.Color | None = None self._font = gui_app.font(FontWeight.NORMAL) - # raw progress arrives in steps, one per 128KB chunk the manager publishes + # raw progress arrives in steps, one per progress report from the manager self._progress = FirstOrderFilter(0.0, 0.5, 1 / gui_app.target_fps) # integrated per frame; (t * speed) % span jumps whenever the fill width changes self._sweep = 0.0 From 1aa3a430fbe0bcf95f9b3a9dd6edfe34b92d0d63 Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:11:24 -0700 Subject: [PATCH 267/270] models: pin tg to @02e6981 (#2071) --- tinygrad_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinygrad_repo b/tinygrad_repo index 69fd4d34d4..92b742f711 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit 69fd4d34d4430036378aba2b43f9d339cdd6ea73 +Subproject commit 92b742f711c7d216fd3ed7a46a30a959e3c43bd4 From f78c6c23773f4e6badcf62ee3ac222eb94c0eeb3 Mon Sep 17 00:00:00 2001 From: Amy Jeanes Date: Thu, 22 Oct 2026 00:13:52 +0100 Subject: [PATCH 268/270] models: publish the download speed and derive the eta from it (#2052) * models: publish the download speed and derive the eta from it Fills in DownloadProgress.speed (bytes per second, declared with the parallel downloader). The reporter measures the rate between its 0.5 s reports, smooths it, and derives the eta from the bytes remaining at that rate instead of extrapolating elapsed time. Speed is zeroed when a download ends, fails or is cancelled and for a cached artifact, and copied across artifacts that share a file like the other progress fields. The big UI's Models page shows "12.3 MB/s" next to the progress bar and the mici models panel shows it beside the percentage. Both take the max across a bundle's artifacts, not the sum, since artifacts sharing a file mirror the same transfer and only one file is ever in flight. A stall decays the smoothed speed toward 0 without ever reaching it, so below 0.05 MB/s, which would only display as "0.0 MB/s", it snaps to 0. Both UIs then show "--.- MB/s" while downloading with no rate (before the first reading or during a stall) instead of a stuck "0.0 MB/s" or nothing, and the speed recovers straight to the real rate once data flows again. The snap also keeps the eta in range: without it a stall of about 25 s drove remaining / speed past the capnp UInt32 eta, and the capnp error failed the download outright instead of leaving the stall to the read timeout and retries. A test holds a piece stalled until the published speed reaches exactly 0, and errors on that overflow without the snap. Assisted-by: Claude Code (Claude Opus 4.8) Assisted-by: Claude Code (Claude Opus 5.5) * remove unneeded comments Assisted-by: Claude Code (Claude Opus 5.5) * models: take the speed baseline before the workers start The reporter took its starting byte count after the piece workers were submitted, and starting each pool thread lets earlier workers run, so bytes landed in that gap never counted toward a speed sample. Take the baseline in _download_file before any worker exists and pass it in. The test runs every piece inline in submit, so the whole file lands before the reporter starts; with the late baseline the speed stays 0. Assisted-by: Claude Code (Claude Opus 5.5) --- .../ui/sunnypilot/layouts/settings/models.py | 4 +- .../ui/sunnypilot/mici/layouts/models.py | 7 +++- openpilot/sunnypilot/models/manager.py | 36 ++++++++++++++---- .../models/tests/test_manager_download.py | 38 ++++++++++++++++++- 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index 1d12f9f5b1..22cc818ff4 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -243,7 +243,9 @@ class ModelsLayout(Widget): if ds.verifying in statuses: return {"name": name, "downloading": True, "progress": progress, "status_text": tr("verifying")} if ds.downloading in statuses: - return {"name": name, "downloading": True, "progress": progress} + speed = max((p.speed for p in progresses), default=0.0) + status_text = f"{speed / 1024 ** 2:.1f} MB/s" if speed > 0 else "--.- MB/s" + return {"name": name, "downloading": True, "progress": progress, "status_text": status_text} if statuses <= {ds.downloaded, ds.cached}: return {"name": name, "text_color": ON_COLOR, "icon": "icons/checkmark.png"} # circled_slash is authored grey; tinting it again only darkens it diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py index 99e0b06e87..c1f1f09155 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -257,6 +257,7 @@ class ModelsLayoutMici(NavScroller): count = 0 verifying = False canceling = False + speed = 0.0 for model in manager.selectedBundle.models: count += 1 p = model.artifact.downloadProgress @@ -265,6 +266,7 @@ class ModelsLayoutMici(NavScroller): progress += p.progress verifying = verifying or p.status == custom.ModelManagerSP.DownloadStatus.verifying canceling = canceling or p.canceling + speed = max(speed, p.speed) # only one file downloads at a time, so the non-zero speed is the live one elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded, custom.ModelManagerSP.DownloadStatus.cached): progress += 100.0 @@ -279,7 +281,10 @@ class ModelsLayoutMici(NavScroller): self.current_model_info.current_model_text.set_text(name_text) self.current_model_info.info_header.set_text(tr("progress") + self._download_progress) self.current_model_info.info_header._shimmer = True - self.current_model_info.info_text.set_text(f"{progress/count:.2f}%") + progress_text = f"{progress/count:.2f}%" + if not verifying and not canceling: + progress_text += f" | {speed / 1024 ** 2:.1f} MB/s" if speed > 0 else " | --.- MB/s" + self.current_model_info.info_text.set_text(progress_text) elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloaded: self.current_model_info.info_header.set_text(tr("downloaded")) diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index bdee8ac94a..f1cca741bd 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -34,6 +34,8 @@ TRANSIENT_HTTP_STATUS = frozenset({408, 429, 500, 502, 503, 504}) REPORT_INTERVAL = 0.5 # seconds between progress publications # byte ranges count encoded bytes but iter_content yields decoded ones, so ask for an unencoded body NO_COMPRESSION = {"Accept-Encoding": "identity"} +SPEED_SMOOTHING = 0.7 # weight of the previous speed sample in the published speed +SPEED_FLOOR = 0.05 * 1024 ** 2 # bytes/s; anything slower would only display as 0.0 MB/s, so it reads as no rate class DownloadCancelled(Exception): @@ -154,6 +156,7 @@ class ModelManagerSP: artifact.downloadProgress.status = source_artifact.downloadProgress.status artifact.downloadProgress.progress = source_artifact.downloadProgress.progress artifact.downloadProgress.eta = source_artifact.downloadProgress.eta + artifact.downloadProgress.speed = source_artifact.downloadProgress.speed def _calculate_eta(self, filename: str, progress: float) -> int: if filename not in self._download_start_times or progress <= 0: @@ -167,28 +170,43 @@ class ModelManagerSP: eta = total_estimated_time - elapsed_time return max(1, int(eta)) # Return at least 1 second if download is ongoing - def _set_progress(self, artifact, status, progress: float, eta: int = 0) -> None: + def _set_progress(self, artifact, status, progress: float, eta: int = 0, speed: float = 0.0) -> None: artifact.downloadProgress.status = status artifact.downloadProgress.progress = progress artifact.downloadProgress.eta = eta + artifact.downloadProgress.speed = speed self._sync_artifact_progress(artifact) self._report_status() - def _publish_progress(self, artifact, done_bytes: int, total: int) -> None: + def _publish_progress(self, artifact, done_bytes: int, total: int, speed: float) -> None: # held at 99 until the file passes its hash check progress = min(99.0, done_bytes / total * 100) if total > 0 else 0.0 - eta = self._calculate_eta(artifact.fileName, progress) - self._set_progress(artifact, custom.ModelManagerSP.DownloadStatus.downloading, progress, eta) + if speed > 0 and total > 0: + eta = max(1, int((total - done_bytes) / speed)) + else: + eta = self._calculate_eta(artifact.fileName, progress) + self._set_progress(artifact, custom.ModelManagerSP.DownloadStatus.downloading, progress, eta, speed) - async def _report_until_done(self, tasks: list[asyncio.Future], artifact, progress: _Progress, total: int) -> None: + async def _report_until_done(self, tasks: list[asyncio.Future], artifact, progress: _Progress, total: int, + started: tuple[int, float]) -> None: pending = set(tasks) + last_bytes, last_time = started + speed = 0.0 while pending: done, pending = await asyncio.wait(pending, timeout=REPORT_INTERVAL) for task in done: task.result() # re-raises a worker failure if self._download_interrupted(): raise DownloadCancelled("Download cancelled") - self._publish_progress(artifact, progress.snapshot(), total) + now = time.monotonic() + done_bytes = progress.snapshot() + if (dt := now - last_time) > 0: + instant = max(0.0, (done_bytes - last_bytes) / dt) # bytes/s this tick; clamped since retried pieces can lower the byte count + speed = instant if speed == 0 else SPEED_SMOOTHING * speed + (1 - SPEED_SMOOTHING) * instant + if speed < SPEED_FLOOR: # a stall decays toward 0 without reaching it, and a near-0 speed overflows the eta + speed = 0.0 + last_time, last_bytes = now, done_bytes + self._publish_progress(artifact, done_bytes, total, speed) async def _download_file(self, url: str, path: str, artifact) -> None: self._download_start_times[artifact.fileName] = time.monotonic() @@ -199,12 +217,13 @@ class ModelManagerSP: await loop.run_in_executor(None, _prepare_target, path, total) progress = _Progress() cancel = threading.Event() + started = progress.snapshot(), time.monotonic() # before any worker can land bytes with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_PIECES) as pool: tasks = [loop.run_in_executor(pool, _fetch_piece, url, path, i, start, end, progress, cancel, self._block_size) for i, (start, end) in enumerate(pieces)] try: - await self._report_until_done(tasks, artifact, progress, total) + await self._report_until_done(tasks, artifact, progress, total, started) except BaseException as e: cancel.set() if isinstance(e, DownloadCancelled): @@ -247,6 +266,7 @@ class ModelManagerSP: self._download_start_times.pop(artifact.fileName, None) artifact.downloadProgress.status = status.failed artifact.downloadProgress.eta = 0 + artifact.downloadProgress.speed = 0 self._sync_artifact_progress(artifact) if self.selected_bundle: self.selected_bundle.status = status.failed @@ -259,6 +279,7 @@ class ModelManagerSP: os.remove(full_path) artifact.downloadProgress.status = status.failed artifact.downloadProgress.eta = 0 + artifact.downloadProgress.speed = 0 self._sync_artifact_progress(artifact) if self.selected_bundle: self.selected_bundle.status = status.failed @@ -301,6 +322,7 @@ class ModelManagerSP: artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached artifact.downloadProgress.progress = 100 artifact.downloadProgress.eta = 0 + artifact.downloadProgress.speed = 0 else: seen_artifacts.add(artifact.fileName) await self._process_artifact(artifact, destination_path) diff --git a/openpilot/sunnypilot/models/tests/test_manager_download.py b/openpilot/sunnypilot/models/tests/test_manager_download.py index 662a776e9b..3446441ee2 100644 --- a/openpilot/sunnypilot/models/tests/test_manager_download.py +++ b/openpilot/sunnypilot/models/tests/test_manager_download.py @@ -6,6 +6,7 @@ See the LICENSE.md file in the root directory for more details. """ import asyncio +import concurrent.futures import contextlib import dataclasses import hashlib @@ -129,6 +130,13 @@ def threaded_server(handler): thread.join() +class InlineExecutor(concurrent.futures.Executor): + def submit(self, fn, /, *args, **kwargs): + future: concurrent.futures.Future = concurrent.futures.Future() + future.set_result(fn(*args, **kwargs)) # the piece lands before the reporter starts + return future + + class ManagerDownloadTestBase(OpenpilotTestCase): def setUp(self): super().setUp() @@ -139,7 +147,7 @@ class ManagerDownloadTestBase(OpenpilotTestCase): self.addCleanup(self._tmp.cleanup) self.dest = self._tmp.name - for name, value in (('PIECE_SIZE', PIECE_SIZE), ('REPORT_INTERVAL', 0.05), ('PIECE_BACKOFF', 0.01)): + for name, value in (('PIECE_SIZE', PIECE_SIZE), ('REPORT_INTERVAL', 0.05), ('PIECE_BACKOFF', 0.01), ('SPEED_FLOOR', 100)): self.enterContext(mock.patch.object(manager_module, name, value)) self.reported: list[float] = [] @@ -229,6 +237,31 @@ class TestManagerDownload(ManagerDownloadTestBase): assert self.reported and self.reported == sorted(self.reported), "progress only rises" assert max(self.reported) <= 99.0, "progress holds below 100 until the hash check" assert artifact.fileName not in self.manager._download_start_times + assert artifact.downloadProgress.speed > 0, "a running download shows its speed" + + def test_speed_counts_bytes_landed_before_the_first_report(self): + self.enterContext(mock.patch.object(manager_module, 'ThreadPoolExecutor', lambda max_workers: InlineExecutor())) + artifact = self.download_file() + assert artifact.downloadProgress.speed > 0 + + def test_eta_comes_from_speed(self): + artifact = self.make_artifact() + self.manager._publish_progress(artifact, done_bytes=400, total=1000, speed=100) + assert artifact.downloadProgress.eta == 6 + + def test_stalled_speed_drops_to_zero(self): + stall_event = self.stall(0) + seen_speed = threading.Event() + + def release_once_zero(*args): + if self.artifact.downloadProgress.speed > 0: + seen_speed.set() + elif seen_speed.is_set(): + stall_event.set() + self.pm.send.side_effect = release_once_zero + self.download_file() + assert self.server.stall_released_by_event is True, "the stalled speed never reached exactly 0" + assert self.artifact.downloadProgress.speed > 0, "the speed must recover once data flows again" def test_pieces_download_in_parallel(self): stall_event = self.stall(0) @@ -303,7 +336,8 @@ class TestProcessArtifact(ManagerDownloadTestBase): self.download_bundle(source) assert ACTIVE_BUNDLE_KEYS[source] in store and ACTIVE_BUNDLE_KEYS[other] not in store assert self.manager.selected_bundle.status == ds.downloaded - assert self.artifact.downloadProgress.progress == 100 and self.artifact.downloadProgress.eta == 0 + assert self.artifact.downloadProgress.progress == 100 + assert self.artifact.downloadProgress.eta == 0 and self.artifact.downloadProgress.speed == 0 assert self.read_path() == WHOLE_BODY os.remove(self.path) From 9d029d683dea3fd034659d6bc291e3eb46669d7e Mon Sep 17 00:00:00 2001 From: James Vecellio-Grant <159560811+Discountchubbs@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:43:42 -0700 Subject: [PATCH 269/270] models: self healing test for tg (#2072) * models: self healing test for tg * rm container * ew * set env * set var --- .github/workflows/test_models.yml | 53 ++++++ .../modeld_v2/convert_upstream_pkl.py | 17 +- openpilot/sunnypilot/modeld_v2/helpers.py | 12 +- .../modeld_v2/plain_artifact_compiler.py | 26 ++- .../tests/test_convert_upstream_pkl.py | 121 +++++++++++++ .../modeld_v2/tests/test_plain_artifact.py | 162 ++++++++++++++++++ 6 files changed, 380 insertions(+), 11 deletions(-) create mode 100644 openpilot/sunnypilot/modeld_v2/tests/test_convert_upstream_pkl.py create mode 100644 openpilot/sunnypilot/modeld_v2/tests/test_plain_artifact.py diff --git a/.github/workflows/test_models.yml b/.github/workflows/test_models.yml index 2345385fca..bf22f14a11 100644 --- a/.github/workflows/test_models.yml +++ b/.github/workflows/test_models.yml @@ -3,9 +3,62 @@ on: pull_request: paths: - 'tinygrad_repo' + - 'openpilot/sunnypilot/modeld_v2/**' workflow_dispatch: +env: + CI: 1 + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: lfs.fetchexclude + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl + jobs: + plain-artifact: + name: Plain artifact round trip and pickle converter + runs-on: ubuntu-24.04 + env: + PYTHONPATH: ${{ github.workspace }}/tinygrad_repo:${{ github.workspace }} + TINYGRAD_TESTS: 1 + steps: + - uses: actions/checkout@v7 + - run: ./tools/op.sh setup + - name: Build openpilot + run: scons -j$(nproc) + - name: Compile, load, cache and fuse a model with the pinned tinygrad + env: + DEV: CPU + run: tools/op.sh test -v openpilot/sunnypilot/modeld_v2/tests/test_plain_artifact.py + - name: Install LLVM 20 and ROCm comgr for the amd mock, as tinygrad_repo/.github/actions/setup-tinygrad does + run: | + . /etc/os-release + curl -fsSL https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc > /dev/null + echo "deb http://apt.llvm.org/$VERSION_CODENAME/ llvm-toolchain-$VERSION_CODENAME-20 main" | sudo tee /etc/apt/sources.list.d/llvm.list + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://repo.radeon.com/rocm/rocm.gpg.key | gpg --dearmor | sudo tee /etc/apt/keyrings/rocm.gpg > /dev/null + echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/7.1 $VERSION_CODENAME main" | sudo tee /etc/apt/sources.list.d/rocm.list + printf 'Package: *\nPin: release o=repo.radeon.com\nPin-Priority: 600\n' | sudo tee /etc/apt/preferences.d/rocm-pin-600 + sudo apt-get -qq update && sudo apt-get -qq install -y --no-install-recommends libllvm20 clang-20 lld-20 comgr + printf '/opt/rocm/lib\n/opt/rocm/lib64\n' | sudo tee /etc/ld.so.conf.d/rocm.conf && sudo ldconfig + echo "/usr/lib/llvm-20/bin" >> "$GITHUB_PATH" + - name: Convert a pickled USB+AMD jit and replay it under tinygrad's amd mock + env: + DEV: MOCKUSB+AMD + CHECK_OOB: 0 + GMMU: 0 + run: tools/op.sh test -v openpilot/sunnypilot/modeld_v2/tests/test_convert_upstream_pkl.py + - name: Where tinygrad moved, for the helper or failure point named above + if: failure() && github.event_name == 'pull_request' + run: | + git fetch -q --depth=1 origin "${{ github.base_ref }}" + old=$(git rev-parse "origin/${{ github.base_ref }}:tinygrad_repo") new=$(git rev-parse HEAD:tinygrad_repo) + git -C tinygrad_repo fetch -q origin "$old" || true + echo "tinygrad_repo $old -> $new, in what helpers.py and plain_artifact_compiler.py read:" + git -C tinygrad_repo diff "$old" "$new" -- tinygrad/uop/ops.py tinygrad/engine/jit.py tinygrad/engine/realize.py tinygrad/device.py \ + tinygrad/helpers.py tinygrad/dtype.py tinygrad/renderer/__init__.py + echo "and in what convert_upstream_pkl.py decodes:" + git -C tinygrad_repo diff --stat "$old" "$new" -- tinygrad/runtime/autogen/am/pm4_nv.py tinygrad/runtime/autogen/amd_gpu.py + git -C tinygrad_repo diff "$old" "$new" -- tinygrad/runtime/support/hcq2.py tinygrad/runtime/support/usb.py tinygrad/runtime/ops_amd.py + generate-matrix: runs-on: ubuntu-latest outputs: diff --git a/openpilot/sunnypilot/modeld_v2/convert_upstream_pkl.py b/openpilot/sunnypilot/modeld_v2/convert_upstream_pkl.py index 74d3b860da..9ae62f0f5d 100755 --- a/openpilot/sunnypilot/modeld_v2/convert_upstream_pkl.py +++ b/openpilot/sunnypilot/modeld_v2/convert_upstream_pkl.py @@ -7,6 +7,7 @@ See the LICENSE.md file in the root directory for more details. """ import argparse import dataclasses +import inspect import itertools import mmap import re @@ -45,6 +46,11 @@ def _address(v): return _root(v.src[0]), add + start, size +def _buffer(u): + from tinygrad.uop.ops import Ops + return u.buffer if u.op is Ops.BUFFER else getattr(u.arg, "buffer", None) + + def _patched(buf, topo) -> tuple[bytearray, dict]: from tinygrad.uop.ops import Ops blob, rows = bytearray(buf.max_numel() * buf.dtype.itemsize), {} @@ -72,7 +78,7 @@ def _evaluate(u, env): table, index = _root(u.src[0].src[0]), _evaluate(u.src[0].src[1], env) return struct.unpack_from(u.dtype.fmt, table.arg, index * u.dtype.itemsize)[0] if table.op is Ops.BINARY and index is not None else None if u.op in GroupOp.ALU and None not in (values := [_evaluate(x, env) for x in u.src]): - return exec_alu(u.op, u.dtype, values, False) + return exec_alu(u.op, u.dtype, values, *([False] if "truncate_output" in inspect.signature(exec_alu).parameters else [])) return None @@ -112,7 +118,7 @@ def convert(jit, kwargs: dict, frame: dict, target) -> tuple[dict, memoryview]: from tinygrad.uop.ops import Ops submit = jit.captured._linear.src[0].without_after kernels, topo = submit.arg.aux.kernels, list(jit.captured._linear.toposort(enter_calls=True)) - allocs = {str(u.tag): u for u in topo if u.op is Ops.ALLOC and u.tag is not None} + allocs = {str(name): u for u in topo if u.op is Ops.ALLOC and (name := getattr(u, "tag", None) or u.arg.name) is not None} (commands, patched), kernargs = _patched(allocs["cmdbuf_compute_0"], topo), allocs["kernargs_compute_0"] argument_words, pokes = _patched(kernargs, topo)[1], _input_pokes(submit, kernargs) sh = {name: getattr(amd_gpu, f"reg{name}") + amd_gpu.GC_BASE__INST0_SEG0 - pm4.PACKET3_SET_SH_REG_START @@ -158,7 +164,7 @@ def convert(jit, kwargs: dict, frame: dict, target) -> tuple[dict, memoryview]: args.append({"input": pokes[slot][0]}) elif slot in argument_words: base, at, nbytes = _address(argument_words[slot]) - args.append({"buffer": buffers.setdefault(base, len(buffers)), "offset": at, "nbytes": nbytes or base.arg.buffer.nbytes, "dtype": u8}) + args.append({"buffer": buffers.setdefault(base, len(buffers)), "offset": at, "nbytes": nbytes or base.max_numel() * base.dtype.itemsize, "dtype": u8}) outs, ins = kernel[6] calls.append({"kind": "program", "name": re.sub(r"\x1b\[[0-9;]*m", "", kernel[1]), "lib": programs[image_alloc], "target": dataclasses.asdict(target), "signature": [[f"data{i}", i, u8, [a.get("nbytes", 1)]] for i, a in enumerate(args)], "global_size": grid, "local_size": local, @@ -166,12 +172,13 @@ def convert(jit, kwargs: dict, frame: dict, target) -> tuple[dict, memoryview]: needs_init |= {args[i]["buffer"] for i in ins if "buffer" in args[i] and args[i]["buffer"] not in written} written |= {args[i]["buffer"] for i in outs if "buffer" in args[i]} + assert all(_buffer(b) is not None for b, i in buffers.items() if i in needs_init), "a kernel reads a scratch before any kernel writes it" names = list(jit.captured.expected_names) inputs = [{"slot": slot, "name": names[slot] if slot < len(names) else None, "dtype": repr(u.dtype), "numel": int(u.max_numel()), "device": u.device} for slot, u in enumerate(_prepare_jit_inputs((), kwargs)[0])] header = {"input_names": names, "calls": calls, "returns": {"kind": "NoneType", "tensors": []}, "inputs": inputs, - "buffers": [{"nbytes": b.arg.buffer.nbytes, "device": b.arg.buffer.device, "dtype": u8} - | ({"init": blob.add(b.arg.buffer.ensure_allocated().as_memoryview())} if i in needs_init else {}) for b, i in buffers.items()], + "buffers": [{"nbytes": bb.nbytes if (bb := _buffer(b)) else b.max_numel() * b.dtype.itemsize, "device": bb.device if bb else b.device, "dtype": u8} + | ({"init": blob.add(bb.ensure_allocated().as_memoryview())} if i in needs_init else {}) for b, i in buffers.items()], "check": {"inputs": [[path, store(array), device] for path, array, device in frame["inputs"]], "outputs": [store(array) for array in frame["outputs"]], "output_container": frame["output_container"]}} with sink: diff --git a/openpilot/sunnypilot/modeld_v2/helpers.py b/openpilot/sunnypilot/modeld_v2/helpers.py index f45584a611..7b79c1736d 100644 --- a/openpilot/sunnypilot/modeld_v2/helpers.py +++ b/openpilot/sunnypilot/modeld_v2/helpers.py @@ -250,6 +250,12 @@ def _allocate_buffers(header: dict, blob: memoryview, device_map: dict | None, s return buffers +def _fields(what, /, **values): # a tinygrad dataclass built or replaced with the fields it has today: a dropped field is fine, a new required one is not + known = {field.name for field in dataclasses.fields(what)} + values = {name: value for name, value in values.items() if name in known} + return what(**values) if isinstance(what, type) else dataclasses.replace(what, **values) + + def _program_uop(call: dict, lib: bytes): from tinygrad.helpers import Target from tinygrad.uop.ops import KernelInfo, Ops, ProgramInfo, UOp @@ -257,9 +263,9 @@ def _program_uop(call: dict, lib: bytes): for name, compact_slot, dtype, shape in call["signature"]: parameter = UOp.param(call["globals"][compact_slot], _dtype(dtype), math.prod(shape)) image = (shape[0], shape[1]) if len(shape) == 3 and shape[2] == 4 else None - parameters.append(parameter.replace(arg=dataclasses.replace(parameter.arg, name=name, image=image))) - info = ProgramInfo(global_size=tuple(call["global_size"]), local_size=tuple(call["local_size"]), vars=(), globals=tuple(call["globals"]), - outs=tuple(call["outs"]), ins=tuple(call["ins"]), target=Target(**call["target"])) + parameters.append(parameter.replace(arg=_fields(parameter.arg, name=name, image=image))) + info = _fields(ProgramInfo, global_size=tuple(call["global_size"]), local_size=tuple(call["local_size"]), globals=tuple(call["globals"]), + outs=tuple(call["outs"]), ins=tuple(call["ins"]), target=_fields(Target, **call["target"])) return UOp(Ops.PROGRAM, src=(UOp(Ops.SINK, arg=KernelInfo(name=call["name"])), UOp(Ops.LINEAR, src=tuple(parameters)), UOp(Ops.SOURCE, arg=""), UOp(Ops.BINARY, arg=lib)), arg=info) diff --git a/openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py b/openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py index 01dd292e29..db761a0540 100644 --- a/openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py +++ b/openpilot/sunnypilot/modeld_v2/plain_artifact_compiler.py @@ -23,7 +23,7 @@ def record_kernel_linear(): recorded, hcq_compile, compile_linear = [], realize.hcq_compile, jit.compile_linear def recording_compile_linear(*args, **kwargs): - with mock.patch.object(realize, "hcq_compile", lambda linear, *a, **k: (recorded.append(linear), hcq_compile(linear, *a, **k))[1]): + with mock.patch.object(realize, "hcq_compile", lambda linear, *a, **k: (linear.src and recorded.append(linear), hcq_compile(linear, *a, **k))[1]): return compile_linear(*args, **kwargs) with mock.patch.object(jit, "compile_linear", recording_compile_linear): @@ -38,6 +38,18 @@ def capture_frame(jit, kwargs: dict, output_container: str | None = None): return {"inputs": inputs, "outputs": [tensor.numpy().copy() for tensor in outputs], "output_container": output_container}, result +def kernel_signature(ast, elf) -> list: + from tinygrad.dtype import AddrSpace + if hasattr(ast, "kernel_params"): + if any(p.arg.addrspace is AddrSpace.ALU for p in ast.kernel_params): + raise ValueError(f"{elf.name} takes a variable, which plain artifacts don't support") + compact = {slot: i for i, slot in enumerate(ast.arg.globals)} + return [[p.arg.name, compact[p.arg.slot], repr(p.dtype), [int(x) for x in p._shape]] for p in ast.kernel_params] + if not all(isinstance(slot, int) for _, slot, _, _ in elf.signature): + raise ValueError("TinyELF.signature lost its slots and this tinygrad has no UOp.kernel_params: update kernel_signature") + return [[name, slot, repr(dtype), [int(x) for x in shape]] for name, slot, dtype, shape in elf.signature] + + def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict, memoryview]: from tinygrad.uop.ops import Ops sink = tempfile.TemporaryFile(dir=".") @@ -51,6 +63,12 @@ def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict, if uop.op is Ops.PARAM: input_specs.setdefault(uop.arg.slot, {"dtype": repr(uop.dtype), "numel": int(uop.max_numel()), "device": uop.device}) return {"input": uop.arg.slot} + root, offset = uop, 0 + while root.op in (Ops.AFTER, Ops.BITCAST, Ops.SHRINK): + offset, root = offset + (root.src[1].arg * root.src[0].dtype.itemsize if root.op is Ops.SHRINK else 0), root.src[0] + if root.op is Ops.ALLOC: + base = bases.setdefault(id(root), (len(bases), root, root.dtype)) + return {"buffer": base[0], "offset": offset, "nbytes": int(uop.max_numel()) * uop.dtype.itemsize, "dtype": repr(uop.dtype)} buffer = uop.buffer root = buffer._base if buffer._base is not None else buffer base = bases.setdefault(id(root), (len(bases), root, uop.base.dtype if buffer._base is None else uop.dtype)) @@ -63,7 +81,7 @@ def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict, elf, info = ast.to_elf(), ast.arg global_size, local_size = info.launch_dims({}) calls.append({"kind": "program", "name": elf.name, "lib": blob.add(bytes(elf.lib)), "target": dataclasses.asdict(elf.target), - "signature": [[name, slot, repr(dtype), [int(x) for x in shape]] for name, slot, dtype, shape in elf.signature], + "signature": kernel_signature(ast, elf), "global_size": [int(x) for x in global_size], "local_size": [int(x) for x in local_size], "globals": list(info.globals), "outs": list(info.outs), "ins": list(info.ins), "args": arguments}) outs, ins = info.outs, info.ins @@ -77,7 +95,9 @@ def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict, ret = jit.captured.ret returns = {"kind": type(ret).__name__, "tensors": [describe(t.uop.base) | {"shape": [int(x) for x in t.shape]} for t in flatten(ret)]} - buffers = [{"nbytes": base.nbytes, "device": base.device, "dtype": repr(dtype)} + from tinygrad.device import Buffer + assert not [base for index, base, _ in bases.values() if index in needs_init and not isinstance(base, Buffer)], "a kernel reads a scratch before writing it" + buffers = [{"nbytes": base.nbytes if isinstance(base, Buffer) else int(base.max_numel()) * dtype.itemsize, "device": base.device, "dtype": repr(dtype)} | ({"init": blob.add(base.as_memoryview())} if index in needs_init else {}) for index, base, dtype in bases.values()] names = list(jit.captured.expected_names) if check is not None: diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_convert_upstream_pkl.py b/openpilot/sunnypilot/modeld_v2/tests/test_convert_upstream_pkl.py new file mode 100644 index 0000000000..3deffcfc71 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/tests/test_convert_upstream_pkl.py @@ -0,0 +1,121 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import os +import re +import sys +import tempfile +import unittest +from unittest import mock +import numpy as np + +os.environ.setdefault("CHECK_OOB", "0") +os.environ.setdefault("GMMU", "0") +if sys.platform == "darwin" and os.environ.get("DEV", "").startswith("MOCKUSB"): # the mock's VRAM is a memfd, which macOS lacks: an unlinked file is the same + from tinygrad.runtime.autogen import libc + + def _memfd_create(name, flags): + fd, path = tempfile.mkstemp() + os.unlink(path) + return fd + libc.memfd_create = _memfd_create + +from openpilot.common.test import OpenpilotTestCase +from openpilot.sunnypilot.modeld_v2 import convert_upstream_pkl +from openpilot.sunnypilot.modeld_v2.convert_upstream_pkl import convert +from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, load_model +from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame + +IMG, DESIRE, STATE, OUT = (6, 8, 8), (8,), (4, 16), (1, 32) + + +class TestConvertUpstreamPkl(OpenpilotTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + if not os.environ.get("TINYGRAD_TESTS"): + raise unittest.SkipTest("TINYGRAD_TESTS not set. skipping converter test") + from tinygrad import Device, Tensor + assert Device.DEFAULT == "AMD" and getattr(Device["AMD"], "is_usb", False), f"{Device.DEFAULT} is not an AMD device behind the USB bridge" + assert (Tensor([1, 2, 3]) * 2).tolist() == [2, 4, 6] + cls.tmp = tempfile.TemporaryDirectory() + cls.rng = np.random.default_rng(0) + cls.weights = Tensor(cls.rng.standard_normal((16, 32)).astype(np.float32)).realize() + cls.bias = Tensor(cls.rng.standard_normal((1, 32)).astype(np.float32)).realize() + cls.input_specs = {"input_imgs": (IMG, "uint8", Device.DEFAULT), "desire": (DESIRE, "float32", Device.DEFAULT), + "features": (STATE, "float32", Device.DEFAULT)} + cls.output_specs = {"outputs": (OUT, "float32", Device.DEFAULT), "next_features": (STATE, "float32", Device.DEFAULT)} + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + super().tearDownClass() + + def model(self, input_imgs, desire, features, output_buffers): + from tinygrad import Tensor + feat = features.mean(axis=0).reshape(1, 16) + input_imgs.float().mean() + desire.sum() + Tensor.realize(output_buffers["outputs"].assign((feat @ self.weights + self.bias).relu()), + output_buffers["next_features"].assign(features[1:].cat(feat * 2))) + + def inputs(self, seed: int) -> dict: + from examples.openpilot.helpers import allocate_inputs + rng = np.random.default_rng(seed) + + def initialize(arrays: dict): + for value in arrays.values(): + value[...] = rng.standard_normal(value.shape) if value.dtype.kind == "f" else rng.integers(0, 256, value.shape) + return allocate_inputs(self.input_specs, initialize) | {"output_buffers": allocate_inputs(self.output_specs)} + + def jit(self): + from tinygrad import TinyJit + run = TinyJit(self.model, prune=True) + kwargs = self.inputs(1) + run(**kwargs) + run(**kwargs) + return run + + def assert_matches_eager(self, plain, seed: int): + fresh, expected = self.inputs(seed), self.inputs(seed) + plain(**fresh) + self.model(**expected) + for name in self.output_specs: + assert np.array_equal(fresh["output_buffers"][name].numpy(), expected["output_buffers"][name].numpy()), name + + def convert(self, jit) -> tuple[dict, memoryview]: + from tinygrad import Device + kwargs = self.inputs(2) + frame, _ = capture_frame(jit, kwargs, "output_buffers") + header, blob = convert(jit, kwargs, frame, Device[Device.DEFAULT].renderer.target) + kernels = jit.captured._linear.src[0].without_after.arg.aux.kernels + assert [call["name"] for call in header["calls"]] == [re.sub(r"\x1b\[[0-9;]*m", "", kernel[1]) for kernel in kernels] + assert all(re.fullmatch(r"\w+", call["name"]) for call in header["calls"]), "kernel names: failure point 1" + assert [(tuple(call["outs"]), tuple(call["ins"])) for call in header["calls"]] == [tuple(kernel[6]) for kernel in kernels], "outs/ins: failure point 1" + bound = {argument["input"] for call in header["calls"] for argument in call["args"] if "input" in argument} + assert bound == {spec["slot"] for spec in header["inputs"]}, f"input slots bound by pokes {sorted(bound)}: failure point 4" + assert [spec["name"] for spec in header["inputs"]][:len(self.input_specs)] == sorted(self.input_specs) + return header, blob + + def test_converted_jit_matches_eager(self): # the submit decoded into plain calls, replayed against its own frame + jit = self.jit() + plain = PlainJit(*self.convert(jit)) + self.assert_matches_eager(plain, 3) + self.assert_matches_eager(plain, 4) + + def test_pickle_round_trip_through_main(self): + from examples.openpilot.helpers import dump_pickle, load_pickle + from tinygrad.engine.realize import lower_and_compile + pkl, out = os.path.join(self.tmp.name, "tiny_tinygrad.pkl"), os.path.join(self.tmp.name, "tiny_plain.pkl") + dump_pickle({"run": self.jit(), "input_specs": self.input_specs, "output_specs": self.output_specs, "metadata": {"name": "tiny"}}, pkl) + + artifact = load_pickle(pkl, out_of_band=True) + artifact["run"].captured._linear = lower_and_compile(artifact["run"].captured._linear) + self.assert_matches_eager(PlainJit(*self.convert(artifact["run"])), 5) + + with mock.patch.object(sys, "argv", ["convert_upstream_pkl", pkl, out]): + convert_upstream_pkl.main() + bundle = load_model(out, cache=False, verify=True) + assert bundle["metadata"] == {"name": "tiny"} and bundle["input_specs"] == self.input_specs and bundle["output_specs"] == self.output_specs + self.assert_matches_eager(bundle["run"], 6) diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_plain_artifact.py b/openpilot/sunnypilot/modeld_v2/tests/test_plain_artifact.py new file mode 100644 index 0000000000..80bebc119b --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/tests/test_plain_artifact.py @@ -0,0 +1,162 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import dataclasses +import functools +import inspect +import os +import sys +import tempfile +from unittest import mock +import numpy as np +import unittest + +import openpilot.sunnypilot.modeld_v2.helpers as helpers +from openpilot.common.test import OpenpilotTestCase +from openpilot.sunnypilot.modeld_v2 import compile_warp +from openpilot.sunnypilot.modeld_v2.frame_fusion import frame_part, pack_layout +from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, cache_is_fresh, load_model, read_parts, write_bundle +from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit + +CAM_W, CAM_H, MODEL_W, MODEL_H = 32, 16, 16, 8 +FRAME = f"{CAM_W},{CAM_H},{CAM_W},{CAM_H},{CAM_H // 2},{CAM_W * CAM_H * 3 // 2}" +FRAME_SIZE = CAM_W * CAM_H * 3 // 2 +HOST = {"desire": (8,), "traffic_convention": (2,)} +STATE, OUT = (4, 16), (1, 32) + + +def compile_jit(fn, inputs: dict, output_container: str | None = None) -> tuple[dict, memoryview]: + from tinygrad import TinyJit + run = TinyJit(fn, prune=True) + with record_kernel_linear() as recorded: + run(**inputs) + run(**inputs) + check, _ = capture_frame(run, inputs, output_container) + assert len(recorded) == 1, f"expected one captured kernel list, got {len(recorded)}" + return serialize_jit(run, recorded[0], check) + + +class TestPlainArtifact(OpenpilotTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + if not os.environ.get("TINYGRAD_TESTS"): + raise unittest.SkipTest("TINYGRAD_TESTS env var not set, skipping artifact test.") + from tinygrad import Tensor + cls.tmp = tempfile.TemporaryDirectory() + cls.rng = np.random.default_rng(0) + cls.weights = Tensor(cls.rng.standard_normal((16, 32)).astype(np.float32)).realize() + cls.bias = Tensor(cls.rng.standard_normal((1, 32)).astype(np.float32)).realize() + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + super().tearDownClass() + + def model(self, x, h): # a two input, one weight model: the shape of a policy without the size + return ((x @ self.weights + self.bias).relu() + h.mean()).contiguous() + + def tensors(self, seed: int): + from tinygrad import Tensor + rng = np.random.default_rng(seed) + return {"x": Tensor(rng.standard_normal((1, 16)).astype(np.float32)).realize(), "h": Tensor(rng.standard_normal((4, 16)).astype(np.float32)).realize()} + + def artifact(self, name: str = "model") -> str: + path = os.path.join(self.tmp.name, f"{name}_tinygrad.pkl") + if not os.path.exists(path): + write_bundle(path, {"run": compile_jit(self.model, self.tensors(1))}, {"metadata": {"name": name}}) + return path + + def test_round_trip_matches_jit(self): # compiler -> bundle -> PlainJit, replay verified, then fresh inputs bit exact with eager tinygrad + bundle = load_model(self.artifact(), cache=False, verify=True) + assert bundle["metadata"] == {"name": "model"} and isinstance(bundle["run"], PlainJit) + fresh = self.tensors(2) + assert np.array_equal(bundle["run"](**fresh).numpy(), self.model(**fresh).numpy()) + + def test_replay_check_catches_a_changed_kernel(self): + parts, extras = read_parts(self.artifact()) + header, blob = parts["run"] + poisoned = dict(header, check=dict(header["check"], outputs=[header["check"]["inputs"][0][1]])) + with self.assertRaises(helpers.ArtifactMismatch): + PlainJit(poisoned, blob) + + def test_cache_round_trip_holds_no_weights_and_is_rebuilt_when_stale(self): + path = self.artifact("cached") + expected = load_model(path, cache=False, verify=False)["run"](**self.tensors(3)).numpy() + load_model(path) + assert cache_is_fresh(path) + with open(f"{path}.cache", "rb") as file: + cache = file.read() + assert self.weights.numpy().tobytes() not in cache and len(cache) < os.path.getsize(path) + with mock.patch.object(helpers, "PlainJit", side_effect=AssertionError("a fresh cache must not rebuild")): + cached = load_model(path) + assert np.array_equal(cached["run"](**self.tensors(3)).numpy(), expected) + with mock.patch.object(helpers, "tinygrad_identity", return_value="another tinygrad"): + assert not cache_is_fresh(path) + assert isinstance(load_model(path)["run"], PlainJit) + assert cache_is_fresh(path) + assert not cache_is_fresh(path) # keyed on the tinygrad that wrote it, so the next boot after a bump rebuilds once + os.utime(path) + with mock.patch.object(helpers, "tinygrad_identity", return_value="another tinygrad"): + assert not cache_is_fresh(path) + + def test_fused_with_warp_matches_warp_then_model(self): + from tinygrad import Tensor + warp_path = os.path.join(self.tmp.name, "warp_tinygrad.pkl") + with mock.patch.object(sys, "argv", ["compile_warp", "--frame", FRAME, "--warp-to", f"{MODEL_W}x{MODEL_H}", "--output", warp_path]): + compile_warp.main() + warp = load_model(warp_path, cache=False)["run"] + + def native(new_img, desire, traffic_convention, state_feat_q, output_buffers): + feat = state_feat_q.mean(axis=0).reshape(1, 16) + new_img.float().mean() + desire.sum() + traffic_convention[0] + Tensor.realize(output_buffers["outputs"].assign((feat @ self.weights + self.bias).relu()), + output_buffers["next_state_feat_q"].assign(state_feat_q[1:].cat(feat * 2))) + + def model_inputs(new_img): + return {"new_img": new_img} | {name: Tensor(np.ones(shape, np.float32) * 0.5).realize() for name, shape in HOST.items()} | \ + {"state_feat_q": Tensor(self.rng.standard_normal(STATE).astype(np.float32)).realize(), + "output_buffers": {"outputs": Tensor.zeros(OUT).contiguous().realize(), "next_state_feat_q": Tensor.zeros(STATE).contiguous().realize()}} + frames = Tensor(self.rng.integers(0, 256, (2, FRAME_SIZE), dtype=np.uint8)).realize() + tfm = Tensor(np.tile(np.eye(3, dtype=np.float32), (2, 1, 1))).realize() + path = os.path.join(self.tmp.name, "native_tinygrad.pkl") + part = compile_jit(native, model_inputs(warp(input_frame=frames, M_inv=tfm).realize()), "output_buffers") + extras = {"input_specs": {"new_img": ((2, 6, MODEL_H // 2, MODEL_W // 2), "|u1", "CPU"), "state_feat_q": (STATE, "= {"global_size", "local_size", "globals", "outs", "ins", "target"}, "ProgramInfo changed: update helpers._program_uop" + assert fields[ParamArg] >= {"slot", "dtype", "name", "image"}, "ParamArg changed: update helpers._program_uop" + assert fields[Target] >= {"device", "renderer", "arch"}, "Target changed: update the 'target' key in plain_artifact_compiler.serialize_jit" + assert "signature" in getattr(TinyELF, "__annotations__", {}) or hasattr(UOp, "kernel_params"), \ + "kernel signatures moved: update plain_artifact_compiler.serialize_jit" + assert callable(getattr(UOp, "param", None)) and callable(getattr(UOp, "from_buffer", None)), "UOp.param/from_buffer: update helpers.PlainJit" + assert callable(getattr(UOp, "store_call", None)) and callable(getattr(UOp, "call", None)), "UOp.call/store_call: update helpers.PlainJit" + assert callable(getattr(Buffer, "view", None)) and callable(getattr(Buffer, "ensure_allocated", None)), "Buffer.view: update helpers._upload" + assert callable(getattr(jit, "compile_linear", None)) and callable(getattr(jit, "_prepare_jit_inputs", None)), "engine.jit: update helpers._Jit" + assert "expected_names" in inspect.signature(jit.CapturedJit).parameters, "CapturedJit: update helpers.PlainJit and plain_artifact_compiler.serialize_jit" From 8e740e0ac680d9a960489c04436a701dd491505c Mon Sep 17 00:00:00 2001 From: Jason Wen Date: Thu, 22 Oct 2026 22:20:29 -0400 Subject: [PATCH 270/270] bump opendbc --- opendbc_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendbc_repo b/opendbc_repo index 5be7507143..e3f1487de4 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 5be750714349624462da012f2f86ea9b686d8b27 +Subproject commit e3f1487de42c4f6de7229ef53da70673f1091b42