Merge branch 'upstream/openpilot/master' into sync-20260401

# Conflicts:
#	.github/workflows/auto_pr_review.yaml
#	.github/workflows/repo-maintenance.yaml
#	README.md
#	SConstruct
#	docs/CARS.md
#	opendbc_repo
#	panda
#	selfdrive/car/card.py
#	selfdrive/controls/controlsd.py
#	selfdrive/selfdrived/selfdrived.py
#	selfdrive/test/process_replay/migration.py
#	selfdrive/ui/translations/app_fr.po
Platform List: sync with latest
Sync: `commaai/panda:master` → `sunnypilot/panda:master`
This commit is contained in:
Jason Wen
2026-04-01 22:45:27 -04:00
150 changed files with 19680 additions and 8979 deletions
+4 -5
View File
@@ -4,7 +4,7 @@ import shutil
import libusb
Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal')
Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib')
# Detect Qt - skip build if not available
if arch == "Darwin":
@@ -18,9 +18,6 @@ else:
if not has_qt:
Return()
SConscript(['#tools/replay/SConscript'])
Import('replay_lib')
qt_env = env.Clone()
qt_modules = ["Widgets", "Gui", "Core"]
@@ -69,7 +66,7 @@ base_frameworks = qt_env['FRAMEWORKS']
base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] + qt_env["LIBS"]
if arch == "Darwin":
base_frameworks.append('QtCharts')
base_frameworks += ['QtCharts', 'CoreFoundation', 'CoreVideo', 'CoreMedia', 'IOKit', 'Security', 'VideoToolbox']
else:
base_libs.append('Qt5Charts')
@@ -78,6 +75,8 @@ cabana_env['CPPPATH'] += [libusb.INCLUDE_DIR]
cabana_env['LIBPATH'] += [libusb.LIB_DIR]
cabana_libs = [cereal, messaging, visionipc, replay_lib, 'avformat', 'avcodec', 'swresample', 'avutil', 'x264', 'z', 'bz2', 'zstd', 'yuv', 'usb-1.0'] + base_libs
if arch != "Darwin":
cabana_libs += ['va', 'va-drm', 'drm']
opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("../../opendbc/dbc").abspath)
cabana_env['CXXFLAGS'] += [opendbc_path]
+1 -1
View File
@@ -33,6 +33,6 @@ fi
# Build _cabana
cd "$ROOT"
scons -j"$(nproc)" tools/cabana/_cabana
scons -j4 tools/cabana/_cabana
exec "$DIR/_cabana" "$@"
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
jot_*.o
*.o
jotpluggler
car_fingerprint_to_dbc.h
generated_dbcs/.stamp
generated_dbcs/*.dbc
layouts/.jotpluggler_autosave/
reports/
+92
View File
@@ -0,0 +1,92 @@
import os
import imgui
import libusb
from opendbc import get_generated_dbcs
from opendbc.car import Bus
from opendbc.car.fingerprints import MIGRATION
from opendbc.car.values import PLATFORMS
from openpilot.common.basedir import BASEDIR
Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib')
jot_env = env.Clone()
jot_env["LIBPATH"] += [imgui.MESA_DIR, libusb.LIB_DIR]
jot_env["CPPPATH"] += [imgui.INCLUDE_DIR, libusb.INCLUDE_DIR]
jot_env["CXXFLAGS"] += [
"-DGLFW_INCLUDE_NONE",
'-DJOTP_REPO_ROOT=\'"%s"\'' % os.path.realpath(BASEDIR),
]
def materialize_generated_dbcs(target, source, env):
out_dir = os.path.dirname(str(target[0]))
os.makedirs(out_dir, exist_ok=True)
for name in os.listdir(out_dir):
if name.endswith('.dbc'):
os.unlink(os.path.join(out_dir, name))
for name, content in sorted(get_generated_dbcs().items()):
with open(os.path.join(out_dir, f"{name}.dbc"), "w") as f:
f.write(content)
with open(str(target[0]), "w") as f:
f.write("ok\n")
return None
def write_car_fingerprint_to_dbc_header(target, source, env):
pairs = {}
for name, platform in sorted(PLATFORMS.items()):
dbc = platform.config.dbc_dict.get(Bus.pt, "")
if not dbc and name.startswith("TESLA_"):
dbc = platform.config.dbc_dict.get(Bus.party, "")
if not dbc and name == "COMMA_BODY":
dbc = "comma_body"
if dbc and name != "MOCK":
pairs[name] = dbc
for fingerprint, car in sorted(MIGRATION.items()):
dbc = pairs.get(str(car), "")
if dbc:
pairs[fingerprint] = dbc
lines = [
"#pragma once",
"",
"#include <string_view>",
"#include <utility>",
"",
"inline constexpr std::pair<std::string_view, std::string_view> kCarFingerprintToDbc[] = {",
]
lines.extend(f' {{"{fingerprint}", "{dbc}"}},' for fingerprint, dbc in sorted(pairs.items()))
lines.extend([
"};",
"",
"inline std::string_view dbc_for_car_fingerprint(std::string_view fingerprint) {",
" for (const auto &[car_fingerprint, dbc] : kCarFingerprintToDbc) {",
" if (car_fingerprint == fingerprint) return dbc;",
" }",
" return {};",
"}",
"",
])
with open(str(target[0]), "w") as f:
f.write("\n".join(lines))
return None
generated_dbc_stamp = jot_env.Command(f"generated_dbcs/.stamp", [], materialize_generated_dbcs)
car_fingerprint_to_dbc = jot_env.Command("car_fingerprint_to_dbc.h", [], write_car_fingerprint_to_dbc_header)
libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a"),
"avformat", "avcodec", "avutil", "x264", "yuv", "z", "bz2", "zstd", "m", "pthread", "usb-1.0"]
if arch == "Darwin":
jot_env["FRAMEWORKS"] = ["OpenGL", "Cocoa", "IOKit", "CoreFoundation", "CoreVideo", "CoreMedia", "VideoToolbox"]
else:
libs += ["GL", "dl", "va", "va-drm", "drm"]
program = jot_env.Program("jotpluggler", jot_env.Glob("*.cc"), LIBS=libs)
jot_env.Depends(program, generated_dbc_stamp)
jot_env.Depends(program, car_fingerprint_to_dbc)
File diff suppressed because it is too large Load Diff
+884
View File
@@ -0,0 +1,884 @@
#pragma once
#include "cereal/gen/cpp/log.capnp.h"
#include "imgui.h"
#include "tools/jotpluggler/dbc.h"
#include "tools/jotpluggler/util.h"
#include <algorithm>
#include <array>
#include <atomic>
#include <cstdint>
#include <filesystem>
#include <future>
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
// *****
// app options & entry point
// *****
struct Options {
std::string layout;
std::string route_name;
std::string data_dir;
std::string output_path;
std::string stream_address = "127.0.0.1";
int width = 1600;
int height = 900;
bool show = false;
bool sync_load = false;
bool stream = false;
double stream_buffer_seconds = 30.0;
};
int run(const Options &options);
// *****
// sketch layout & route data
// *****
struct PlotRange {
bool valid = false;
double left = 0.0;
double right = 0.0;
double bottom = 0.0;
double top = 1.0;
bool has_y_limit_min = false;
bool has_y_limit_max = false;
double y_limit_min = 0.0;
double y_limit_max = 1.0;
};
struct CustomPythonSeries {
std::string linked_source;
std::vector<std::string> additional_sources;
std::string globals_code;
std::string function_code;
};
struct Curve {
std::string name;
std::string label;
std::array<uint8_t, 3> color = {160, 170, 180};
bool visible = true;
bool derivative = false;
double derivative_dt = 0.0;
double value_scale = 1.0;
double value_offset = 0.0;
bool runtime_only = false;
std::optional<CustomPythonSeries> custom_python;
std::string runtime_error_message;
std::vector<double> xs;
std::vector<double> ys;
};
enum class PaneKind : uint8_t {
Plot,
Map,
Camera,
};
enum class CameraViewKind : uint8_t {
Road,
Driver,
WideRoad,
QRoad,
};
struct Pane {
PaneKind kind = PaneKind::Plot;
CameraViewKind camera_view = CameraViewKind::Road;
std::string title;
PlotRange range;
std::vector<Curve> curves;
};
enum class SplitOrientation {
Horizontal,
Vertical,
};
struct WorkspaceNode {
bool is_pane = false;
int pane_index = -1;
SplitOrientation orientation = SplitOrientation::Horizontal;
std::vector<float> sizes;
std::vector<WorkspaceNode> children;
};
struct WorkspaceTab {
std::string tab_name;
WorkspaceNode root;
std::vector<Pane> panes;
};
struct RouteSeries {
std::string path;
std::vector<double> times;
std::vector<double> values;
};
struct CameraSegmentFile {
int segment = -1;
std::string path;
};
struct CameraFrameIndexEntry {
double timestamp = 0.0;
int segment = -1;
int decode_index = -1;
uint32_t frame_id = 0;
};
struct CameraFeedIndex {
std::vector<CameraSegmentFile> segment_files;
std::vector<CameraFrameIndexEntry> entries;
};
enum class LogOrigin : uint8_t {
Log,
Android,
Alert,
};
struct LogEntry {
double mono_time = 0.0;
double boot_time = 0.0;
double wall_time = 0.0;
uint8_t level = 20;
std::string source;
std::string func;
std::string message;
std::string context;
LogOrigin origin = LogOrigin::Log;
};
struct EnumInfo {
std::vector<std::string> names;
};
struct SeriesFormat {
int decimals = 3;
bool integer_like = false;
bool has_negative = false;
int digits_before = 1;
int total_width = 0;
char fmt[16] = "%7.3f";
};
enum class CanServiceKind : uint8_t {
Can,
Sendcan,
};
struct CanMessageId {
CanServiceKind service = CanServiceKind::Can;
uint8_t bus = 0;
uint32_t address = 0;
bool operator==(const CanMessageId &other) const {
return service == other.service && bus == other.bus && address == other.address;
}
};
struct CanMessageIdHash {
size_t operator()(const CanMessageId &id) const {
return (static_cast<size_t>(id.service) << 40)
^ (static_cast<size_t>(id.bus) << 32)
^ static_cast<size_t>(id.address);
}
};
struct CanFrameSample {
double mono_time = 0.0;
uint16_t bus_time = 0;
std::string data;
};
struct LiveCanFrame {
double mono_time = 0.0;
uint8_t bus = 0;
uint32_t address = 0;
uint16_t bus_time = 0;
std::string data;
};
struct CanMessageData {
CanMessageId id;
std::vector<CanFrameSample> samples;
};
struct TimelineEntry {
enum class Type : uint8_t {
None,
Engaged,
AlertInfo,
AlertWarning,
AlertCritical,
};
double start_time = 0.0;
double end_time = 0.0;
Type type = Type::None;
};
struct GpsPoint {
double time = 0.0;
double lat = 0.0;
double lon = 0.0;
float bearing = 0.0f;
TimelineEntry::Type type = TimelineEntry::Type::None;
};
struct GpsTrace {
std::vector<GpsPoint> points;
double min_lat = 0.0;
double max_lat = 0.0;
double min_lon = 0.0;
double max_lon = 0.0;
};
enum class LogSelector : uint8_t {
Auto,
RLog,
QLog,
};
struct RouteIdentifier {
std::string dongle_id;
std::string log_id;
int slice_begin = 0;
int slice_end = -1;
bool slice_explicit = false;
LogSelector selector = LogSelector::Auto;
bool selector_explicit = false;
int available_begin = 0;
int available_end = 0;
bool empty() const {
return dongle_id.empty() || log_id.empty();
}
std::string canonical() const {
return empty() ? std::string() : dongle_id + "/" + log_id;
}
std::string onebox() const {
return empty() ? std::string() : dongle_id + "|" + log_id;
}
std::string display_slice() const {
const int begin = slice_explicit ? slice_begin : available_begin;
const int end = slice_explicit ? slice_end : available_end;
if (end < 0 || end == begin) {
return std::to_string(begin);
}
return std::to_string(begin) + ":" + std::to_string(end);
}
char selector_char() const {
switch (selector) {
case LogSelector::RLog: return 'r';
case LogSelector::QLog: return 'q';
case LogSelector::Auto:
default: return 'a';
}
}
std::string full_spec() const {
if (empty()) return {};
std::string spec = dongle_id + "/" + log_id;
if (slice_explicit) {
spec += "/";
spec += display_slice();
}
if (selector_explicit) {
spec += "/";
spec.push_back(selector_char());
}
return spec;
}
};
struct RouteData {
std::vector<RouteSeries> series;
std::vector<std::string> paths;
std::vector<std::string> roots;
std::vector<CanMessageData> can_messages;
CameraFeedIndex road_camera;
CameraFeedIndex driver_camera;
CameraFeedIndex wide_road_camera;
CameraFeedIndex qroad_camera;
GpsTrace gps_trace;
std::vector<LogEntry> logs;
std::vector<TimelineEntry> timeline;
std::unordered_map<std::string, EnumInfo> enum_info;
std::unordered_map<std::string, SeriesFormat> series_formats;
std::string car_fingerprint;
std::string dbc_name;
RouteIdentifier route_id;
bool has_time_range = false;
double x_min = 0.0;
double x_max = 1.0;
};
struct StreamExtractBatch {
std::vector<RouteSeries> series;
std::vector<CanMessageData> can_messages;
std::vector<LogEntry> logs;
std::vector<TimelineEntry> timeline;
std::unordered_map<std::string, EnumInfo> enum_info;
std::string car_fingerprint;
std::string dbc_name;
bool has_time_offset = false;
double time_offset = 0.0;
};
struct SketchLayout {
std::vector<WorkspaceTab> tabs;
std::vector<std::string> roots;
int current_tab_index = 0;
};
enum class RouteLoadStage {
Resolving,
DownloadingSegment,
ParsingSegment,
Finished,
};
struct RouteLoadProgress {
RouteLoadStage stage = RouteLoadStage::Resolving;
size_t segment_index = 0;
size_t segment_count = 0;
uint64_t current = 0;
uint64_t total = 0;
size_t segments_downloaded = 0;
size_t segments_parsed = 0;
size_t total_segments = 0;
uint64_t bytes_downloaded = 0;
int num_workers = 1;
std::string segment_name;
};
using RouteLoadProgressCallback = std::function<void(const RouteLoadProgress &)>;
class StreamAccumulator {
public:
explicit StreamAccumulator(const std::string &dbc_name = {}, std::optional<double> time_offset = std::nullopt);
~StreamAccumulator();
StreamAccumulator(const StreamAccumulator &) = delete;
StreamAccumulator &operator=(const StreamAccumulator &) = delete;
void setDbcName(const std::string &dbc_name);
void appendEvent(cereal::Event::Which which, kj::ArrayPtr<const capnp::word> data);
void appendCanFrames(CanServiceKind service, const std::vector<LiveCanFrame> &frames);
StreamExtractBatch takeBatch();
const std::string &carFingerprint() const;
const std::string &dbc_name() const;
std::optional<double> timeOffset() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
SketchLayout load_sketch_layout(const std::filesystem::path &layout_path);
std::vector<std::string> available_dbc_names();
std::vector<std::string> collect_route_roots_for_paths(const std::vector<std::string> &paths);
std::optional<dbc::Database> load_dbc_by_name(const std::string &dbc_name);
std::vector<RouteSeries> decode_can_messages(const std::vector<CanMessageData> &can_messages,
const std::string &dbc_name,
std::unordered_map<std::string, EnumInfo> *enum_info = nullptr);
RouteData load_route_data(const std::string &route_name,
const std::string &data_dir = {},
const std::string &dbc_name = {},
const RouteLoadProgressCallback &progress = {});
RouteIdentifier parse_route_identifier(std::string_view route_name);
void rebuild_gps_trace(RouteData *route_data);
// *****
// icons
// *****
namespace icon {
constexpr const char ARROW_DOWN_UP[] = "\xef\x84\xa7";
constexpr const char ARROW_LEFT_RIGHT[] = "\xef\x84\xab";
constexpr const char BAR_CHART[] = "\xef\x85\xbe";
constexpr const char BOX_ARROW_UP_RIGHT[] = "\xef\x87\x85";
constexpr const char CLIPBOARD[] = "\xef\x8a\x90";
constexpr const char CLIPBOARD2[] = "\xef\x9c\xb3";
constexpr const char DISTRIBUTE_HORIZONTAL[] = "\xef\x8c\x83";
constexpr const char DISTRIBUTE_VERTICAL[] = "\xef\x8c\x84";
constexpr const char FILE_EARMARK_IMAGE[] = "\xef\x8d\xad";
constexpr const char FILES[] = "\xef\x8f\x82";
constexpr const char INFO_CIRCLE[] = "\xef\x90\xb1";
constexpr const char PALETTE[] = "\xef\x92\xb1";
constexpr const char PLUS_SLASH_MINUS[] = "\xef\x9a\xaa";
constexpr const char SAVE[] = "\xef\x94\xa5";
constexpr const char SLIDERS[] = "\xef\x95\xab";
constexpr const char TRASH[] = "\xef\x97\x9e";
constexpr const char X_SQUARE[] = "\xef\x98\xa9";
constexpr const char ZOOM_OUT[] = "\xef\x98\xad";
} // namespace icon
void icon_add_font(float size, bool merge = false, const ImFont *base_font = nullptr);
bool icon_menu_item(const char *glyph,
const char *label,
const char *shortcut = nullptr,
bool selected = false,
bool enabled = true);
// *****
// app session, UI state, & internal API
// *****
class AsyncRouteLoader;
class CameraFeedView;
class StreamPoller;
class MapDataManager;
enum class SessionDataMode : uint8_t {
Route,
Stream,
};
enum class StreamSourceKind : uint8_t {
CerealLocal,
CerealRemote,
};
struct StreamSourceConfig {
StreamSourceKind kind = StreamSourceKind::CerealLocal;
std::string address = "127.0.0.1";
};
struct BrowserNode {
std::string label;
std::string full_path;
std::vector<BrowserNode> children;
};
struct AppSession {
std::filesystem::path layout_path;
std::filesystem::path autosave_path;
std::string route_name;
std::string data_dir;
std::string dbc_override;
StreamSourceConfig stream_source;
double stream_buffer_seconds = 30.0;
SessionDataMode data_mode = SessionDataMode::Route;
RouteIdentifier route_id;
SketchLayout layout;
RouteData route_data;
std::unordered_map<std::string, RouteSeries *> series_by_path;
std::vector<BrowserNode> browser_nodes;
std::unique_ptr<AsyncRouteLoader> route_loader;
std::unique_ptr<StreamPoller> stream_poller;
std::array<std::unique_ptr<CameraFeedView>, 4> pane_camera_feeds;
std::unique_ptr<MapDataManager> map_data;
bool async_route_loading = false;
double next_stream_custom_refresh_time = 0.0;
bool stream_paused = false;
std::optional<double> stream_time_offset;
};
struct TabUiState {
struct MapPaneState {
bool initialized = false;
bool follow = false;
float zoom = 1.0f;
double center_lat = 0.0;
double center_lon = 0.0;
};
struct CameraPaneState {
bool fit_to_pane = true;
};
bool dock_needs_build = true;
int active_pane_index = 0;
int runtime_id = 0;
ImVec2 last_dockspace_size = ImVec2(0.0f, 0.0f);
std::vector<MapPaneState> map_panes;
std::vector<CameraPaneState> camera_panes;
};
struct CustomSeriesEditorState {
bool open = false;
bool open_help = false;
bool request_select = false;
bool selected = false;
bool focus_name = false;
int selected_template = 0;
int selected_additional_source = -1;
std::string name;
std::string linked_source;
std::vector<std::string> additional_sources;
std::string globals_code;
std::string function_code = "return value";
std::string preview_label;
std::vector<double> preview_xs;
std::vector<double> preview_ys;
bool preview_is_result = false;
};
enum class LogTimeMode : uint8_t {
Route,
Boot,
WallClock,
};
struct LogsUiState {
bool selected = false;
bool request_select = false;
bool all_sources = true;
uint32_t enabled_levels_mask = 0b11110;
int expanded_index = -1;
std::string search;
std::vector<std::string> selected_sources;
double last_auto_scroll_time = -1.0;
LogTimeMode time_mode = LogTimeMode::Route;
};
struct AxisLimitsEditorState {
bool open = false;
int pane_index = -1;
double x_min = 0.0;
double x_max = 1.0;
bool y_min_enabled = false;
bool y_max_enabled = false;
double y_min = 0.0;
double y_max = 1.0;
};
struct DbcEditorState {
bool open = false;
bool loaded = false;
std::string source_name;
std::filesystem::path source_path;
enum class SourceKind : uint8_t {
None,
Generated,
Opendbc,
};
SourceKind source_kind = SourceKind::None;
std::string save_name;
std::string text;
};
enum class TimelineDragMode : uint8_t {
None,
ScrubCursor,
PanViewport,
ResizeLeft,
ResizeRight,
};
struct UndoStack {
static constexpr size_t kMaxHistory = 50;
std::vector<SketchLayout> history;
int position = -1;
void reset(const SketchLayout &layout) {
history.clear();
history.push_back(layout);
position = 0;
}
void push(const SketchLayout &layout) {
if (position < 0) {
reset(layout);
return;
}
if (position + 1 < static_cast<int>(history.size())) {
history.resize(static_cast<size_t>(position + 1));
}
history.push_back(layout);
if (history.size() > kMaxHistory) {
history.erase(history.begin());
}
position = static_cast<int>(history.size()) - 1;
}
bool can_undo() const {
return position > 0;
}
bool can_redo() const {
return position >= 0 && position + 1 < static_cast<int>(history.size());
}
const SketchLayout &undo() {
return history[static_cast<size_t>(--position)];
}
const SketchLayout &redo() {
return history[static_cast<size_t>(++position)];
}
};
struct UiState {
bool open_open_route = false;
bool open_stream = false;
bool open_load_layout = false;
bool open_save_layout = false;
bool open_preferences = false;
bool open_find_signal = false;
bool request_close = false;
bool request_reset_layout = false;
bool request_save_layout = false;
bool request_new_tab = false;
bool request_duplicate_tab = false;
bool request_close_tab = false;
bool follow_latest = false;
bool has_shared_range = false;
bool has_tracker_time = false;
bool layout_dirty = false;
bool playback_loop = false;
bool playback_playing = false;
bool show_deprecated_fields = false;
bool show_fps_overlay = false;
bool fps_overlay_initialized = false;
bool suppress_range_side_effects = false;
bool browser_nodes_dirty = false;
int active_tab_index = 0;
int next_tab_runtime_id = 1;
int requested_tab_index = -1;
int rename_tab_index = -1;
bool focus_rename_tab_input = false;
std::vector<TabUiState> tabs;
std::string route_buffer;
std::string stream_address_buffer;
std::string rename_tab_buffer;
std::string browser_filter;
std::string data_dir_buffer;
std::string load_layout_buffer;
std::string save_layout_buffer;
std::string find_signal_buffer;
std::string selected_browser_path;
std::vector<std::string> selected_browser_paths;
std::string browser_selection_anchor;
std::string route_slice_buffer;
std::string error_text;
bool open_error_popup = false;
std::string status_text = "Ready";
std::string route_copy_feedback_text;
double route_copy_feedback_until = 0.0;
bool editing_route_slice = false;
bool focus_route_slice_input = false;
StreamSourceKind stream_source_kind = StreamSourceKind::CerealLocal;
float sidebar_width = 320.0f;
double route_x_min = 0.0;
double route_x_max = 1.0;
double x_view_min = 0.0;
double x_view_max = 1.0;
double tracker_time = 0.0;
double playback_rate = 1.0;
double playback_step = 0.1;
double stream_buffer_seconds = 30.0;
TimelineDragMode timeline_drag_mode = TimelineDragMode::None;
double timeline_drag_anchor_time = 0.0;
double timeline_drag_anchor_x_min = 0.0;
double timeline_drag_anchor_x_max = 0.0;
AxisLimitsEditorState axis_limits;
DbcEditorState dbc_editor;
CustomSeriesEditorState custom_series;
LogsUiState logs;
UndoStack undo;
};
// app.cc public API
const WorkspaceTab *app_active_tab(const SketchLayout &layout, const UiState &state);
WorkspaceTab *app_active_tab(SketchLayout *layout, const UiState &state);
TabUiState *app_active_tab_state(UiState *state);
void app_push_mono_font();
void app_pop_mono_font();
bool app_add_curve_to_active_pane(AppSession *session, UiState *state, const std::string &path);
std::string app_curve_display_name(const Curve &curve);
std::array<uint8_t, 3> app_next_curve_color(const Pane &pane);
const RouteSeries *app_find_route_series(const AppSession &session, const std::string &path);
void app_decimate_samples(const std::vector<double> &xs_in,
const std::vector<double> &ys_in,
int max_points,
std::vector<double> *xs_out,
std::vector<double> *ys_out);
std::optional<double> app_sample_xy_value_at_time(const std::vector<double> &xs,
const std::vector<double> &ys,
bool stairs,
double tm);
void save_layout_json(const SketchLayout &layout, const std::filesystem::path &path);
// *****
// browser
// *****
void rebuild_route_index(AppSession *session);
void rebuild_browser_nodes(AppSession *session, UiState *state);
SeriesFormat compute_series_format(const std::vector<double> &values, bool enum_like = false);
std::string format_display_value(double display_value,
const SeriesFormat &format,
const EnumInfo *enum_info);
std::vector<std::string> decode_browser_drag_payload(std::string_view payload);
void collect_visible_leaf_paths(const BrowserNode &node,
const std::string &filter,
std::vector<std::string> *out);
void draw_browser_node(AppSession *session,
const BrowserNode &node,
UiState *state,
const std::string &filter,
const std::vector<std::string> &visible_paths);
// *****
// custom series
// *****
void open_custom_series_editor(UiState *state, const std::string &preferred_source = {});
std::string preferred_custom_series_source(const Pane &pane);
void refresh_all_custom_curves(AppSession *session, UiState *state);
void draw_custom_series_editor(AppSession *session, UiState *state);
// *****
// logs
// *****
void draw_logs_tab(AppSession *session, UiState *state);
// *****
// map
// *****
void draw_map_pane(AppSession *session, UiState *state, Pane *pane, int pane_index);
// *****
// runtime (GLFW, async loaders, streaming, camera)
// *****
struct GLFWwindow;
struct RouteLoadSnapshot {
bool active = false;
size_t total_segments = 0;
size_t segments_downloaded = 0;
size_t segments_parsed = 0;
};
struct StreamPollSnapshot {
bool active = false;
bool connected = false;
bool paused = false;
StreamSourceKind source_kind = StreamSourceKind::CerealLocal;
std::string source_label;
std::string dbc_name;
std::string car_fingerprint;
double buffer_seconds = 30.0;
uint64_t received_messages = 0;
};
class GlfwRuntime {
public:
explicit GlfwRuntime(const Options &options);
~GlfwRuntime();
GlfwRuntime(const GlfwRuntime &) = delete;
GlfwRuntime &operator=(const GlfwRuntime &) = delete;
GLFWwindow *window() const;
private:
GLFWwindow *window_ = nullptr;
};
class ImGuiRuntime {
public:
explicit ImGuiRuntime(GLFWwindow *window);
~ImGuiRuntime();
ImGuiRuntime(const ImGuiRuntime &) = delete;
ImGuiRuntime &operator=(const ImGuiRuntime &) = delete;
};
class TerminalRouteProgress {
public:
explicit TerminalRouteProgress(bool enabled);
~TerminalRouteProgress();
TerminalRouteProgress(const TerminalRouteProgress &) = delete;
TerminalRouteProgress &operator=(const TerminalRouteProgress &) = delete;
void update(const RouteLoadProgress &progress);
void finish();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
class AsyncRouteLoader {
public:
explicit AsyncRouteLoader(bool enable_terminal_progress);
~AsyncRouteLoader();
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);
RouteLoadSnapshot snapshot() const;
bool consume(RouteData *route_data, std::string *error_text);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
class StreamPoller {
public:
StreamPoller();
~StreamPoller();
StreamPoller(const StreamPoller &) = delete;
StreamPoller &operator=(const StreamPoller &) = delete;
void start(const StreamSourceConfig &source,
double buffer_seconds,
const std::string &dbc_name,
std::optional<double> time_offset = std::nullopt);
void setPaused(bool paused);
void stop();
StreamPollSnapshot snapshot() const;
bool consume(StreamExtractBatch *batch, std::string *error_text);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
class CameraFeedView {
public:
CameraFeedView();
~CameraFeedView();
CameraFeedView(const CameraFeedView &) = delete;
CameraFeedView &operator=(const CameraFeedView &) = delete;
void setRouteData(const RouteData &route_data);
void setCameraIndex(const CameraFeedIndex &camera_index, CameraViewKind view);
void update(double tracker_time);
void draw(float width, bool loading);
void drawSized(ImVec2 size, bool loading, bool fit_to_pane = false);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
+465
View File
@@ -0,0 +1,465 @@
#include "tools/jotpluggler/app.h"
#include "imgui_internal.h"
#include <cmath>
#include <cstdio>
#include <unordered_set>
namespace {
constexpr float BROWSER_VALUE_WIDTH = 88.0f;
bool path_matches_filter(const std::string &path, const std::string &lower_filter) {
if (lower_filter.empty()) return true;
return lowercase_copy(path).find(lower_filter) != std::string::npos;
}
void insert_browser_path(std::vector<BrowserNode> *nodes, const std::string &path) {
size_t start = 0;
while (start < path.size() && path[start] == '/') {
++start;
}
std::vector<std::string> parts;
while (start < path.size()) {
const size_t end = path.find('/', start);
parts.push_back(path.substr(start, end == std::string::npos ? std::string::npos : end - start));
if (end == std::string::npos) break;
start = end + 1;
}
if (parts.empty()) {
return;
}
std::vector<BrowserNode> *current_nodes = nodes;
std::string current_path;
for (size_t i = 0; i < parts.size(); ++i) {
if (!current_path.empty()) {
current_path += "/";
}
current_path += parts[i];
auto it = std::find_if(current_nodes->begin(), current_nodes->end(),
[&](const BrowserNode &node) { return node.label == parts[i]; });
if (it == current_nodes->end()) {
current_nodes->push_back(BrowserNode{.label = parts[i]});
it = std::prev(current_nodes->end());
}
if (i + 1 == parts.size()) {
it->full_path = "/" + current_path;
}
current_nodes = &it->children;
}
}
void sort_browser_nodes(std::vector<BrowserNode> *nodes) {
std::sort(nodes->begin(), nodes->end(), [](const BrowserNode &a, const BrowserNode &b) {
if (a.children.empty() != b.children.empty()) {
return !a.children.empty();
}
return a.label < b.label;
});
for (BrowserNode &node : *nodes) {
sort_browser_nodes(&node.children);
}
}
std::vector<BrowserNode> build_browser_tree(const std::vector<std::string> &paths) {
std::vector<BrowserNode> nodes;
for (const std::string &path : paths) {
insert_browser_path(&nodes, path);
}
sort_browser_nodes(&nodes);
return nodes;
}
bool is_deprecated_browser_path(const std::string &path) {
return path.find("DEPRECATED") != std::string::npos;
}
std::vector<std::string> visible_browser_paths(const RouteData &route_data, bool show_deprecated_fields) {
if (show_deprecated_fields) return route_data.paths;
std::vector<std::string> filtered;
filtered.reserve(route_data.paths.size());
for (const std::string &path : route_data.paths) {
if (!is_deprecated_browser_path(path)) {
filtered.push_back(path);
}
}
return filtered;
}
bool browser_selection_contains(const UiState &state, std::string_view path) {
return std::find(state.selected_browser_paths.begin(), state.selected_browser_paths.end(), path)
!= state.selected_browser_paths.end();
}
std::vector<std::string> browser_drag_paths(const UiState &state, const std::string &dragged_path) {
if (browser_selection_contains(state, dragged_path) && !state.selected_browser_paths.empty()) {
return state.selected_browser_paths;
}
return {dragged_path};
}
std::string encode_browser_drag_payload(const std::vector<std::string> &paths) {
std::string payload;
for (size_t i = 0; i < paths.size(); ++i) {
if (i != 0) {
payload.push_back('\n');
}
payload += paths[i];
}
return payload;
}
void set_browser_selection_single(UiState *state, const std::string &path) {
state->selected_browser_paths = {path};
state->selected_browser_path = path;
state->browser_selection_anchor = path;
}
void toggle_browser_selection(UiState *state, const std::string &path) {
auto it = std::find(state->selected_browser_paths.begin(), state->selected_browser_paths.end(), path);
if (it == state->selected_browser_paths.end()) {
state->selected_browser_paths.push_back(path);
} else {
state->selected_browser_paths.erase(it);
}
state->selected_browser_path = path;
state->browser_selection_anchor = path;
if (state->selected_browser_paths.empty()) {
state->selected_browser_path.clear();
}
}
void select_browser_range(UiState *state, const std::vector<std::string> &visible_paths, const std::string &clicked_path) {
if (visible_paths.empty()) {
set_browser_selection_single(state, clicked_path);
return;
}
const std::string anchor = state->browser_selection_anchor.empty() ? clicked_path : state->browser_selection_anchor;
const auto anchor_it = std::find(visible_paths.begin(), visible_paths.end(), anchor);
const auto clicked_it = std::find(visible_paths.begin(), visible_paths.end(), clicked_path);
if (clicked_it == visible_paths.end()) {
return;
}
if (anchor_it == visible_paths.end()) {
set_browser_selection_single(state, clicked_path);
return;
}
const auto [begin_it, end_it] = std::minmax(anchor_it, clicked_it);
std::vector<std::string> selected;
selected.reserve(static_cast<size_t>(std::distance(begin_it, end_it)) + 1);
for (auto it = begin_it; it != end_it + 1; ++it) {
selected.push_back(*it);
}
state->selected_browser_paths = std::move(selected);
state->selected_browser_path = clicked_path;
}
void prune_browser_selection(UiState *state, const std::vector<std::string> &visible_paths) {
const std::unordered_set<std::string> visible_set(visible_paths.begin(), visible_paths.end());
auto is_visible = [&](const std::string &path) {
return visible_set.count(path) > 0;
};
state->selected_browser_paths.erase(
std::remove_if(state->selected_browser_paths.begin(), state->selected_browser_paths.end(),
[&](const std::string &path) { return !is_visible(path); }),
state->selected_browser_paths.end());
if (!state->selected_browser_path.empty() && !is_visible(state->selected_browser_path)) {
state->selected_browser_path.clear();
}
if (!state->browser_selection_anchor.empty() && !is_visible(state->browser_selection_anchor)) {
state->browser_selection_anchor.clear();
}
if (state->selected_browser_paths.empty()) {
state->selected_browser_path.clear();
} else if (state->selected_browser_path.empty()) {
state->selected_browser_path = state->selected_browser_paths.back();
}
}
std::optional<double> sample_route_series_value(const RouteSeries &series, double tm, bool stairs) {
return app_sample_xy_value_at_time(series.times, series.values, stairs, tm);
}
std::string browser_series_value_text(const AppSession &session, const UiState &state, std::string_view path) {
auto it = session.series_by_path.find(std::string(path));
if (it == session.series_by_path.end() || it->second == nullptr) return {};
const RouteSeries &series = *it->second;
if (series.values.empty()) return {};
const auto enum_it = session.route_data.enum_info.find(series.path);
const EnumInfo *enum_info = enum_it == session.route_data.enum_info.end() ? nullptr : &enum_it->second;
const bool stairs = enum_info != nullptr;
std::optional<double> value;
if (state.has_tracker_time) {
value = sample_route_series_value(series, state.tracker_time, stairs);
} else {
value = series.values.back();
}
if (!value.has_value()) return {};
const auto display_it = session.route_data.series_formats.find(series.path);
const SeriesFormat display_info = display_it == session.route_data.series_formats.end()
? compute_series_format(series.values, enum_info != nullptr)
: display_it->second;
return format_display_value(*value, display_info, enum_info);
}
bool browser_node_matches(const BrowserNode &node, const std::string &filter) {
if (filter.empty()) return true;
if (!node.full_path.empty() && path_matches_filter(node.full_path, filter)) {
return true;
}
for (const BrowserNode &child : node.children) {
if (browser_node_matches(child, filter)) return true;
}
return false;
}
} // namespace
namespace {
int decimals_needed(double value) {
const double abs_value = std::abs(value);
if (abs_value < 1.0e-12) return 0;
for (int decimals = 0; decimals <= 6; ++decimals) {
const double scale = std::pow(10.0, decimals);
if (std::abs(abs_value * scale - std::round(abs_value * scale)) < 1.0e-6) {
return decimals;
}
}
return 6;
}
void finalize_series_format(SeriesFormat *format) {
format->digits_before = std::max(format->digits_before, 1);
format->decimals = std::clamp(format->decimals, 0, 6);
format->integer_like = format->decimals == 0;
const int sign_width = format->has_negative ? 1 : 0;
const int dot_width = format->decimals > 0 ? 1 : 0;
format->total_width = sign_width + format->digits_before + dot_width + format->decimals;
std::snprintf(format->fmt, sizeof(format->fmt), "%%%d.%df", format->total_width, format->decimals);
}
} // namespace
SeriesFormat compute_series_format(const std::vector<double> &values, bool enum_like) {
SeriesFormat format;
if (values.empty()) return format;
const size_t step = std::max<size_t>(1, values.size() / 256);
bool saw_finite = false;
bool all_integer = enum_like;
double min_value = 0.0;
double max_value = 0.0;
int max_needed_decimals = 0;
for (size_t i = 0; i < values.size(); i += step) {
const double value = values[i];
if (!std::isfinite(value)) continue;
if (!saw_finite) {
min_value = value;
max_value = value;
saw_finite = true;
} else {
min_value = std::min(min_value, value);
max_value = std::max(max_value, value);
}
if (std::abs(value - std::round(value)) > 1.0e-9) {
all_integer = false;
}
if (!all_integer) {
max_needed_decimals = std::max(max_needed_decimals, decimals_needed(value));
}
}
if (!saw_finite) return format;
format.has_negative = min_value < 0.0;
const double peak = std::max(std::abs(min_value), std::abs(max_value));
format.digits_before = peak < 1.0 ? 1 : static_cast<int>(std::floor(std::log10(peak))) + 1;
if (enum_like || all_integer) {
format.decimals = 0;
} else if (peak >= 1000.0) {
format.decimals = std::min(max_needed_decimals, 1);
} else if (peak >= 100.0) {
format.decimals = std::min(max_needed_decimals, 2);
} else {
format.decimals = std::min(max_needed_decimals, 4);
}
finalize_series_format(&format);
return format;
}
std::string format_display_value(double display_value,
const SeriesFormat &display_info,
const EnumInfo *enum_info) {
if (!std::isfinite(display_value)) return "---";
if (enum_info != nullptr) {
const int idx = static_cast<int>(std::llround(display_value));
if (idx >= 0 && std::abs(display_value - static_cast<double>(idx)) < 0.01
&& static_cast<size_t>(idx) < enum_info->names.size()
&& !enum_info->names[static_cast<size_t>(idx)].empty()) {
return enum_info->names[static_cast<size_t>(idx)];
}
}
char buf[64] = {};
std::snprintf(buf, sizeof(buf), display_info.fmt, display_value);
return buf;
}
std::vector<std::string> decode_browser_drag_payload(std::string_view payload) {
std::vector<std::string> out;
size_t begin = 0;
while (begin <= payload.size()) {
const size_t end = payload.find('\n', begin);
const size_t length = (end == std::string_view::npos ? payload.size() : end) - begin;
if (length > 0) {
out.emplace_back(payload.substr(begin, length));
}
if (end == std::string_view::npos) break;
begin = end + 1;
}
return out;
}
void collect_visible_leaf_paths(const BrowserNode &node,
const std::string &filter,
std::vector<std::string> *out) {
if (!browser_node_matches(node, filter)) {
return;
}
if (node.children.empty()) {
if (!node.full_path.empty()) {
out->push_back(node.full_path);
}
return;
}
for (const BrowserNode &child : node.children) {
collect_visible_leaf_paths(child, filter, out);
}
}
void rebuild_browser_nodes(AppSession *session, UiState *state) {
const std::vector<std::string> paths = visible_browser_paths(session->route_data, state->show_deprecated_fields);
session->browser_nodes = build_browser_tree(paths);
prune_browser_selection(state, paths);
}
void rebuild_route_index(AppSession *session) {
session->series_by_path.clear();
session->route_data.series_formats.clear();
for (RouteSeries &series : session->route_data.series) {
session->series_by_path.emplace(series.path, &series);
const bool enum_like = session->route_data.enum_info.find(series.path) != session->route_data.enum_info.end();
session->route_data.series_formats.emplace(series.path, compute_series_format(series.values, enum_like));
}
}
void draw_browser_node(AppSession *session,
const BrowserNode &node,
UiState *state,
const std::string &filter,
const std::vector<std::string> &visible_paths) {
if (!browser_node_matches(node, filter)) {
return;
}
if (node.children.empty()) {
const bool selected = browser_selection_contains(*state, node.full_path);
const std::string value_text = browser_series_value_text(*session, *state, node.full_path);
const ImGuiStyle &style = ImGui::GetStyle();
const ImVec2 row_size(std::max(1.0f, ImGui::GetContentRegionAvail().x), ImGui::GetFrameHeight());
ImGui::PushID(node.full_path.c_str());
const bool clicked = ImGui::InvisibleButton("##browser_leaf", row_size);
const bool hovered = ImGui::IsItemHovered();
const bool held = ImGui::IsItemActive();
const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax());
ImDrawList *draw_list = ImGui::GetWindowDrawList();
if (selected || hovered) {
const ImU32 bg = ImGui::GetColorU32(selected
? (held ? ImGuiCol_HeaderActive : ImGuiCol_Header)
: ImGuiCol_HeaderHovered);
draw_list->AddRectFilled(rect.Min, rect.Max, bg, 0.0f);
}
const float value_right = rect.Max.x - style.FramePadding.x;
const float value_left = value_right - (value_text.empty() ? 0.0f : BROWSER_VALUE_WIDTH);
const float label_left = rect.Min.x + style.FramePadding.x;
const float label_right = value_text.empty()
? rect.Max.x - style.FramePadding.x
: std::max(label_left + 40.0f, value_left - 10.0f);
ImGui::RenderTextEllipsis(draw_list,
ImVec2(label_left, rect.Min.y + style.FramePadding.y),
ImVec2(label_right, rect.Max.y),
label_right,
node.label.c_str(),
nullptr,
nullptr);
if (!value_text.empty()) {
app_push_mono_font();
ImGui::PushStyleColor(ImGuiCol_Text, selected ? color_rgb(70, 77, 86) : color_rgb(116, 124, 133));
ImGui::RenderTextClipped(ImVec2(value_left, rect.Min.y + style.FramePadding.y),
ImVec2(value_right, rect.Max.y),
value_text.c_str(),
nullptr,
nullptr,
ImVec2(1.0f, 0.0f));
ImGui::PopStyleColor();
app_pop_mono_font();
}
if (clicked) {
const bool shift_down = ImGui::GetIO().KeyShift;
const bool ctrl_down = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeySuper;
if (shift_down) {
select_browser_range(state, visible_paths, node.full_path);
} else if (ctrl_down) {
toggle_browser_selection(state, node.full_path);
} else {
set_browser_selection_single(state, node.full_path);
}
}
if (hovered && ImGui::IsMouseDoubleClicked(0)) {
set_browser_selection_single(state, node.full_path);
app_add_curve_to_active_pane(session, state, node.full_path);
}
if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) {
const std::vector<std::string> drag_paths = browser_drag_paths(*state, node.full_path);
const std::string payload = encode_browser_drag_payload(drag_paths);
ImGui::SetDragDropPayload("JOTP_BROWSER_PATHS", payload.c_str(), payload.size() + 1);
if (drag_paths.size() == 1) {
ImGui::TextUnformatted(drag_paths.front().c_str());
} else {
ImGui::Text("%zu timeseries", drag_paths.size());
ImGui::TextUnformatted(drag_paths.front().c_str());
}
ImGui::EndDragDropSource();
}
ImGui::PopID();
return;
}
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_SpanAvailWidth;
if (!filter.empty()) {
flags |= ImGuiTreeNodeFlags_DefaultOpen;
}
const bool open = ImGui::TreeNodeEx(node.label.c_str(), flags);
if (open) {
for (const BrowserNode &child : node.children) {
draw_browser_node(session, child, state, filter, visible_paths);
}
ImGui::TreePop();
}
}
+54
View File
@@ -0,0 +1,54 @@
#include "tools/jotpluggler/camera.h"
#include "imgui.h"
#include "imgui_internal.h"
namespace {
bool draw_camera_fit_toggle_overlay(bool fit_to_pane) {
const ImVec2 window_pos = ImGui::GetWindowPos();
const ImVec2 content_min = ImGui::GetWindowContentRegionMin();
const ImRect rect(ImVec2(window_pos.x + content_min.x + 8.0f, window_pos.y + content_min.y + 8.0f),
ImVec2(window_pos.x + content_min.x + 58.0f, window_pos.y + content_min.y + 28.0f));
const bool hovered = ImGui::IsMouseHoveringRect(rect.Min, rect.Max, false);
const bool held = hovered && ImGui::IsMouseDown(ImGuiMouseButton_Left);
if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImDrawList *draw_list = ImGui::GetWindowDrawList();
draw_list->AddRectFilled(rect.Min, rect.Max, hovered ? IM_COL32(255, 255, 255, 234) : IM_COL32(255, 255, 255, 214), 4.0f);
draw_list->AddRect(rect.Min, rect.Max, IM_COL32(184, 189, 196, 255), 4.0f, 0, 1.0f);
const ImRect box(ImVec2(rect.Min.x + 6.0f, rect.Min.y + 4.0f), ImVec2(rect.Min.x + 18.0f, rect.Min.y + 16.0f));
draw_list->AddRect(box.Min, box.Max, IM_COL32(112, 120, 129, 255), 2.0f, 0, 1.0f);
if (fit_to_pane) {
draw_list->AddLine(ImVec2(box.Min.x + 2.5f, box.Min.y + 6.5f), ImVec2(box.Min.x + 5.5f, box.Max.y - 2.5f), IM_COL32(60, 111, 202, 255), 1.8f);
draw_list->AddLine(ImVec2(box.Min.x + 5.5f, box.Max.y - 2.5f), ImVec2(box.Max.x - 2.5f, box.Min.y + 2.5f), IM_COL32(60, 111, 202, 255), 1.8f);
}
draw_list->AddText(ImVec2(box.Max.x + 6.0f, rect.Min.y + 3.0f), IM_COL32(72, 79, 88, 255), "Fit");
return hovered && !held && ImGui::IsMouseReleased(ImGuiMouseButton_Left);
}
} // namespace
void draw_camera_pane(AppSession *session, UiState *state, TabUiState *tab_state, int pane_index, const Pane &pane) {
CameraFeedView *feed = session->pane_camera_feeds[static_cast<size_t>(pane.camera_view)].get();
if (feed == nullptr) {
ImGui::TextDisabled("Camera unavailable");
return;
}
const bool fit_to_pane = tab_state != nullptr
&& pane_index >= 0
&& pane_index < static_cast<int>(tab_state->camera_panes.size())
? tab_state->camera_panes[static_cast<size_t>(pane_index)].fit_to_pane
: true;
if (state->has_tracker_time) {
feed->update(state->tracker_time);
}
feed->drawSized(ImGui::GetContentRegionAvail(), session->async_route_loading, fit_to_pane);
if (tab_state != nullptr
&& pane_index >= 0
&& pane_index < static_cast<int>(tab_state->camera_panes.size())
&& draw_camera_fit_toggle_overlay(fit_to_pane)) {
tab_state->camera_panes[static_cast<size_t>(pane_index)].fit_to_pane = !fit_to_pane;
}
}
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include "tools/jotpluggler/app.h"
void draw_camera_pane(AppSession *session, UiState *state, TabUiState *tab_state, int pane_index, const Pane &pane);
+179
View File
@@ -0,0 +1,179 @@
#include "tools/jotpluggler/common.h"
#include <algorithm>
#include <array>
#include <cstdlib>
namespace {
std::string format_coord(const GpsPoint &point) {
return util::string_format("%.5f,%.5f", point.lat, point.lon);
}
} // namespace
const CameraViewSpec &camera_view_spec(CameraViewKind view) {
auto it = std::find_if(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) {
return spec.view == view;
});
return it != kCameraViewSpecs.end() ? *it : kCameraViewSpecs.front();
}
const CameraViewSpec *camera_view_spec_from_special_item(std::string_view item_id) {
auto it = std::find_if(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) {
return item_id == spec.special_item_id;
});
return it != kCameraViewSpecs.end() ? &*it : nullptr;
}
const CameraViewSpec *camera_view_spec_from_layout_name(std::string_view layout_name) {
auto it = std::find_if(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) {
return layout_name == spec.layout_name;
});
return it != kCameraViewSpecs.end() ? &*it : nullptr;
}
const SpecialItemSpec *special_item_spec(std::string_view item_id) {
auto it = std::find_if(kSpecialItemSpecs.begin(), kSpecialItemSpecs.end(), [&](const SpecialItemSpec &spec) {
return item_id == spec.id;
});
return it != kSpecialItemSpecs.end() ? &*it : nullptr;
}
const char *special_item_label(std::string_view item_id) {
const SpecialItemSpec *spec = special_item_spec(item_id);
return spec != nullptr ? spec->label : "Item";
}
bool pane_kind_is_special(PaneKind kind) {
return kind == PaneKind::Map || kind == PaneKind::Camera;
}
bool is_default_special_title(std::string_view title) {
if (title == "Map") return true;
return std::any_of(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) {
return title == spec.label;
});
}
CameraViewKind sidebar_preview_camera_view(const AppSession &session) {
return session.route_data.road_camera.entries.empty() && !session.route_data.qroad_camera.entries.empty()
? CameraViewKind::QRoad
: CameraViewKind::Road;
}
const std::filesystem::path &repo_root() {
static const std::filesystem::path root(JOTP_REPO_ROOT);
return root;
}
ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha) {
return timeline_entry_color(type, alpha, {111, 143, 175});
}
ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha, std::array<uint8_t, 3> none_color) {
switch (type) {
case TimelineEntry::Type::Engaged:
return ImGui::GetColorU32(color_rgb(0, 163, 108, alpha));
case TimelineEntry::Type::AlertInfo:
return ImGui::GetColorU32(color_rgb(255, 195, 0, alpha));
case TimelineEntry::Type::AlertWarning:
case TimelineEntry::Type::AlertCritical:
return ImGui::GetColorU32(color_rgb(199, 0, 57, alpha));
case TimelineEntry::Type::None:
default:
return ImGui::GetColorU32(color_rgb(none_color, alpha));
}
}
const char *timeline_entry_label(TimelineEntry::Type type) {
static constexpr const char *kLabels[] = {
"disengaged",
"engaged",
"alert info",
"alert warning",
"alert critical",
};
const size_t index = static_cast<size_t>(type);
return index < std::size(kLabels) ? kLabels[index] : kLabels[0];
}
TimelineEntry::Type timeline_type_at_time(const std::vector<TimelineEntry> &timeline, double time_value) {
for (const TimelineEntry &entry : timeline) {
if (time_value >= entry.start_time && time_value <= entry.end_time) {
return entry.type;
}
}
return TimelineEntry::Type::None;
}
std::string normalize_stream_address(std::string address) {
return is_local_stream_address(address) ? "127.0.0.1" : address;
}
const char *stream_source_kind_label(StreamSourceKind kind) {
static constexpr const char *kLabels[] = {
"Local (MSGQ)",
"Remote (ZMQ)",
};
const size_t index = static_cast<size_t>(kind);
return index < std::size(kLabels) ? kLabels[index] : kLabels[0];
}
std::string stream_source_target_label(const StreamSourceConfig &source) {
switch (source.kind) {
case StreamSourceKind::CerealRemote:
return normalize_stream_address(source.address);
case StreamSourceKind::CerealLocal:
default:
return "127.0.0.1";
}
}
bool env_flag_enabled(const char *name, bool default_value) {
const char *raw = std::getenv(name);
if (raw == nullptr || raw[0] == '\0') {
return default_value;
}
const std::string value = lowercase_copy(util::strip(raw));
return !(value == "0" || value == "false" || value == "no" || value == "off");
}
void open_external_url(std::string_view url) {
#ifdef __APPLE__
const std::string command = "open " + shell_quote(url) + " &";
#else
const std::string command = "xdg-open " + shell_quote(url) + " >/dev/null 2>&1 &";
#endif
util::check_system(command);
}
std::string route_useradmin_url(const RouteIdentifier &route_id) {
return route_id.empty() ? std::string()
: "https://useradmin.comma.ai/?onebox=" + route_id.dongle_id + "%7C" + route_id.log_id;
}
std::string route_connect_url(const RouteIdentifier &route_id) {
return route_id.empty() ? std::string()
: "https://connect.comma.ai/" + route_id.canonical();
}
std::string route_google_maps_url(const GpsTrace &trace) {
if (trace.points.size() < 2) {
return {};
}
const std::string prefix = "https://www.google.com/maps/dir/?api=1&travelmode=driving&origin="
+ format_coord(trace.points.front()) + "&destination=" + format_coord(trace.points.back());
for (size_t n = std::min<size_t>(9, trace.points.size() > 2 ? trace.points.size() - 2 : 0); ; --n) {
std::string url = prefix;
if (n > 0) {
url += "&waypoints=";
for (size_t i = 0; i < n; ++i) {
if (i) url += "%7C";
url += format_coord(trace.points[1 + ((trace.points.size() - 2) * (i + 1)) / (n + 1)]);
}
}
if (url.size() <= 1900 || n == 0) return url;
}
}
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include "tools/jotpluggler/app.h"
#include <array>
#include <string_view>
struct CameraViewSpec {
CameraViewKind view = CameraViewKind::Road;
const char *label = "";
const char *runtime_name = "";
const char *layout_name = "";
const char *special_item_id = "";
CameraFeedIndex RouteData::*route_member = nullptr;
};
struct SpecialItemSpec {
const char *id = "";
const char *label = "";
PaneKind kind = PaneKind::Plot;
CameraViewKind camera_view = CameraViewKind::Road;
};
inline constexpr std::array<CameraViewSpec, 4> kCameraViewSpecs = {{
{CameraViewKind::Road, "Road Camera", "road", "road", "camera_road", &RouteData::road_camera},
{CameraViewKind::Driver, "Driver Camera", "driver", "driver", "camera_driver", &RouteData::driver_camera},
{CameraViewKind::WideRoad, "Wide Road Camera", "wide", "wide_road", "camera_wide_road", &RouteData::wide_road_camera},
{CameraViewKind::QRoad, "qRoad Camera", "qroad", "qroad", "camera_qroad", &RouteData::qroad_camera},
}};
inline constexpr std::array<SpecialItemSpec, 5> kSpecialItemSpecs = {{
{"map", "Map", PaneKind::Map, CameraViewKind::Road},
{kCameraViewSpecs[0].special_item_id, kCameraViewSpecs[0].label, PaneKind::Camera, kCameraViewSpecs[0].view},
{kCameraViewSpecs[1].special_item_id, kCameraViewSpecs[1].label, PaneKind::Camera, kCameraViewSpecs[1].view},
{kCameraViewSpecs[2].special_item_id, kCameraViewSpecs[2].label, PaneKind::Camera, kCameraViewSpecs[2].view},
{kCameraViewSpecs[3].special_item_id, kCameraViewSpecs[3].label, PaneKind::Camera, kCameraViewSpecs[3].view},
}};
const CameraViewSpec &camera_view_spec(CameraViewKind view);
const CameraViewSpec *camera_view_spec_from_special_item(std::string_view item_id);
const CameraViewSpec *camera_view_spec_from_layout_name(std::string_view layout_name);
const SpecialItemSpec *special_item_spec(std::string_view item_id);
const char *special_item_label(std::string_view item_id);
bool pane_kind_is_special(PaneKind kind);
bool is_default_special_title(std::string_view title);
CameraViewKind sidebar_preview_camera_view(const AppSession &session);
const std::filesystem::path &repo_root();
ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha = 1.0f);
ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha, std::array<uint8_t, 3> none_color);
const char *timeline_entry_label(TimelineEntry::Type type);
TimelineEntry::Type timeline_type_at_time(const std::vector<TimelineEntry> &timeline, double time_value);
std::string normalize_stream_address(std::string address);
const char *stream_source_kind_label(StreamSourceKind kind);
std::string stream_source_target_label(const StreamSourceConfig &source);
bool env_flag_enabled(const char *name, bool default_value = false);
void open_external_url(std::string_view url);
std::string route_useradmin_url(const RouteIdentifier &route_id);
std::string route_connect_url(const RouteIdentifier &route_id);
std::string route_google_maps_url(const GpsTrace &trace);
+750
View File
@@ -0,0 +1,750 @@
#include "tools/jotpluggler/app.h"
#include "tools/jotpluggler/common.h"
#include "implot.h"
#include <cfloat>
#include <chrono>
#include <cstring>
#include <regex>
#include <set>
#include <stdexcept>
#include <unistd.h>
#include "third_party/json11/json11.hpp"
namespace fs = std::filesystem;
namespace {
struct PythonEvalResult {
std::vector<double> xs;
std::vector<double> ys;
};
struct CustomSeriesTemplate {
const char *name;
const char *globals_code;
const char *function_code;
const char *preview_text;
int required_additional_sources;
const char *requirement_text;
};
void write_binary_vector(const fs::path &path, const std::vector<double> &values) {
write_file_or_throw(path, values.data(), values.size() * sizeof(double));
}
std::vector<double> read_binary_vector(const fs::path &path) {
const std::string raw = read_file_or_throw(path);
if (raw.size() % sizeof(double) != 0) {
throw std::runtime_error("Invalid binary series file: " + path.string());
}
std::vector<double> values(raw.size() / sizeof(double));
if (!values.empty()) {
std::memcpy(values.data(), raw.data(), raw.size());
}
return values;
}
void write_text_file(const fs::path &path, std::string_view text) {
write_file_or_throw(path, text);
}
fs::path create_custom_series_temp_dir() {
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
const fs::path dir = fs::temp_directory_path() / ("jotpluggler_math_" + std::to_string(::getpid()) + "_" + std::to_string(stamp));
fs::create_directories(dir);
return dir;
}
void reset_custom_series_editor(CustomSeriesEditorState *editor) {
*editor = CustomSeriesEditorState{};
}
bool add_additional_source(CustomSeriesEditorState *editor, const std::string &path) {
if (path.empty() || path == editor->linked_source) return false;
if (std::find(editor->additional_sources.begin(), editor->additional_sources.end(), path) != editor->additional_sources.end()) {
return false;
}
editor->additional_sources.push_back(path);
return true;
}
std::string next_custom_curve_name(const Pane &pane) {
std::set<std::string> used;
for (const Curve &curve : pane.curves) {
if (!curve.label.empty()) {
used.insert(curve.label);
}
if (!curve.name.empty()) {
used.insert(curve.name);
}
}
for (int i = 1; i < 1000; ++i) {
const std::string candidate = "series" + std::to_string(i);
if (used.find(candidate) == used.end()) {
return candidate;
}
}
return "series";
}
Curve make_custom_curve(const Pane &pane,
const std::string &name,
const CustomPythonSeries &spec,
PythonEvalResult result) {
Curve curve;
curve.name = name;
curve.label = name;
curve.color = app_next_curve_color(pane);
curve.runtime_only = true;
curve.custom_python = spec;
curve.xs = std::move(result.xs);
curve.ys = std::move(result.ys);
return curve;
}
bool upsert_custom_curve_in_pane(WorkspaceTab *tab, int pane_index, Curve curve) {
if (pane_index < 0 || pane_index >= static_cast<int>(tab->panes.size())) {
return false;
}
Pane &pane = tab->panes[static_cast<size_t>(pane_index)];
for (Curve &existing : pane.curves) {
if (existing.runtime_only && existing.name == curve.name) {
existing.visible = true;
existing.label = curve.label;
existing.custom_python = curve.custom_python;
existing.xs = std::move(curve.xs);
existing.ys = std::move(curve.ys);
return false;
}
}
pane.curves.push_back(std::move(curve));
return true;
}
std::set<std::string> collect_custom_series_paths(const CustomPythonSeries &spec,
std::string_view globals_code,
std::string_view function_code) {
std::set<std::string> paths;
if (!spec.linked_source.empty()) {
paths.insert(spec.linked_source);
}
paths.insert(spec.additional_sources.begin(), spec.additional_sources.end());
static const std::regex kPathRegex(R"([tv]\(\s*["']([^"']+)["']\s*\))");
const auto collect_from = [&](std::string_view code) {
std::string owned(code);
for (std::sregex_iterator it(owned.begin(), owned.end(), kPathRegex), end; it != end; ++it) {
paths.insert((*it)[1].str());
}
};
collect_from(globals_code);
collect_from(function_code);
return paths;
}
PythonEvalResult evaluate_custom_python_series(const AppSession &session,
const CustomPythonSeries &spec) {
const std::set<std::string> referenced_paths =
collect_custom_series_paths(spec, spec.globals_code, spec.function_code);
if (referenced_paths.empty()) throw std::runtime_error("No input series referenced. Set an input timeseries or reference route paths in code.");
const fs::path temp_dir = create_custom_series_temp_dir();
try {
const fs::path globals_path = temp_dir / "globals.py";
const fs::path code_path = temp_dir / "code.py";
const fs::path manifest_path = temp_dir / "manifest.json";
const fs::path out_t_path = temp_dir / "result.t.bin";
const fs::path out_v_path = temp_dir / "result.v.bin";
write_text_file(globals_path, spec.globals_code);
write_text_file(code_path, spec.function_code);
json11::Json::array paths_json(session.route_data.paths.begin(), session.route_data.paths.end());
json11::Json::array additional_json(spec.additional_sources.begin(), spec.additional_sources.end());
json11::Json::array series_json;
size_t series_index = 0;
for (const std::string &path : referenced_paths) {
const RouteSeries *series = app_find_route_series(session, path);
if (series == nullptr || series->times.size() < 2 || series->times.size() != series->values.size()) {
throw std::runtime_error("Missing route series " + path);
}
const std::string prefix = "series_" + std::to_string(series_index++);
const fs::path time_path = temp_dir / (prefix + ".t.bin");
const fs::path value_path = temp_dir / (prefix + ".v.bin");
write_binary_vector(time_path, series->times);
write_binary_vector(value_path, series->values);
series_json.push_back(json11::Json::object{
{"path", path}, {"t", time_path.string()}, {"v", value_path.string()}});
}
const json11::Json manifest_json = json11::Json::object{
{"paths", std::move(paths_json)},
{"linked_source", spec.linked_source},
{"additional_sources", std::move(additional_json)},
{"series", std::move(series_json)},
};
write_text_file(manifest_path, manifest_json.dump());
const CommandResult process = run_process_capture_output({
"python3",
(repo_root() / "tools" / "jotpluggler" / "math_eval.py").string(),
manifest_path.string(),
globals_path.string(),
code_path.string(),
out_t_path.string(),
out_v_path.string(),
});
if (process.exit_code != 0) {
const std::string error_text = util::strip(process.output);
throw std::runtime_error(error_text.empty() ? "Python evaluation failed" : error_text);
}
PythonEvalResult result;
result.xs = read_binary_vector(out_t_path);
result.ys = read_binary_vector(out_v_path);
if (result.xs.size() < 2 || result.xs.size() != result.ys.size()) {
throw std::runtime_error("Custom series returned invalid output");
}
fs::remove_all(temp_dir);
return result;
} catch (...) {
std::error_code ignore_error;
fs::remove_all(temp_dir, ignore_error);
throw;
}
}
void refresh_custom_curve_samples(AppSession *session, UiState *state, Curve *curve) {
if (!curve->custom_python.has_value()) {
return;
}
if (!session->route_data.has_time_range || session->route_data.series.empty()) {
curve->runtime_error_message.clear();
curve->xs.clear();
curve->ys.clear();
return;
}
try {
PythonEvalResult result = evaluate_custom_python_series(*session, *curve->custom_python);
curve->runtime_error_message.clear();
curve->xs = std::move(result.xs);
curve->ys = std::move(result.ys);
} catch (const std::exception &err) {
curve->xs.clear();
curve->ys.clear();
const std::string err_text = err.what();
if (session->data_mode == SessionDataMode::Stream && util::starts_with(err_text, "Missing route series ")) {
curve->runtime_error_message = err_text;
return;
}
const std::string error_message = std::string("Failed to evaluate custom series \"")
+ app_curve_display_name(*curve) + "\":\n\n" + err_text;
if (curve->runtime_error_message != error_message) {
curve->runtime_error_message = error_message;
state->error_text = error_message;
state->open_error_popup = true;
}
}
}
const std::array<CustomSeriesTemplate, 4> &custom_series_templates() {
static constexpr std::array<CustomSeriesTemplate, 4> kTemplates = {{
{
.name = "Derivative",
.globals_code = "",
.function_code = "return np.gradient(value, time)",
.preview_text = "return np.gradient(value, time)",
.required_additional_sources = 0,
.requirement_text = "",
},
{
.name = "Difference",
.globals_code = "",
.function_code = "return value - v1",
.preview_text = "Requires one additional source timeseries.\n\nreturn value - v1",
.required_additional_sources = 1,
.requirement_text = "Difference requires one additional source timeseries for v1.",
},
{
.name = "Smoothing",
.globals_code = "window = 20\nweights = np.ones(window) / window",
.function_code = "return np.convolve(value, weights, mode='same')",
.preview_text = "window = 20\nweights = np.ones(window) / window\n\nreturn np.convolve(value, weights, mode='same')",
.required_additional_sources = 0,
.requirement_text = "",
},
{
.name = "Integral",
.globals_code = "",
.function_code = "dt = np.mean(np.diff(time))\nreturn np.cumsum(value) * dt",
.preview_text = "dt = np.mean(np.diff(time))\nreturn np.cumsum(value) * dt",
.required_additional_sources = 0,
.requirement_text = "",
},
}};
return kTemplates;
}
void draw_custom_series_help_popup(CustomSeriesEditorState *editor) {
if (editor->open_help) {
ImGui::OpenPopup("Custom Series Help");
editor->open_help = false;
}
if (!ImGui::BeginPopupModal("Custom Series Help", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
return;
}
ImGui::TextUnformatted("Available variables");
ImGui::Separator();
ImGui::BulletText("np: numpy");
ImGui::BulletText("t(path), v(path): timestamps and values for a route series");
ImGui::BulletText("paths: all available route series paths");
ImGui::BulletText("time, value: linked input timeseries");
ImGui::BulletText("t1, v1, t2, v2, ...: additional source timeseries");
ImGui::Spacing();
ImGui::TextWrapped("Write either a single expression like \"return np.gradient(value, time)\" "
"or a multi-line Python body that returns an array or a (times, values) tuple.");
ImGui::Spacing();
if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
void draw_custom_series_preview(const AppSession &session, CustomSeriesEditorState *editor) {
std::vector<double> preview_xs;
std::vector<double> preview_ys;
std::string preview_label = editor->preview_label;
if (editor->preview_is_result && editor->preview_xs.size() > 1 && editor->preview_xs.size() == editor->preview_ys.size()) {
preview_xs = editor->preview_xs;
preview_ys = editor->preview_ys;
if (preview_label.empty()) {
preview_label = "Result preview";
}
} else if (!editor->linked_source.empty()) {
if (const RouteSeries *series = app_find_route_series(session, editor->linked_source); series != nullptr
&& series->times.size() > 1 && series->times.size() == series->values.size()) {
preview_xs = series->times;
preview_ys = series->values;
preview_label = "Input preview (not result)";
}
}
if (!preview_xs.empty() && preview_xs.size() == preview_ys.size()) {
std::vector<double> plot_xs;
std::vector<double> plot_ys;
app_decimate_samples(preview_xs, preview_ys, 1200, &plot_xs, &plot_ys);
const double preview_x_min = preview_xs.front();
const double preview_x_max = preview_xs.back() > preview_xs.front()
? preview_xs.back()
: preview_xs.front() + 1e-6;
std::string plot_id = "##custom_series_preview";
if (editor->preview_is_result) {
plot_id += "_result_";
plot_id += editor->name.empty() ? preview_label : editor->name;
} else if (!editor->linked_source.empty()) {
plot_id += "_input_";
plot_id += editor->linked_source;
}
ImGui::TextUnformatted(preview_label.c_str());
if (!editor->linked_source.empty() && !editor->preview_is_result) {
ImGui::SameLine();
ImGui::TextDisabled("%s", editor->linked_source.c_str());
}
if (ImPlot::BeginPlot(plot_id.c_str(),
ImVec2(-1.0f, std::max(180.0f, ImGui::GetContentRegionAvail().y - 6.0f)),
ImPlotFlags_NoTitle | ImPlotFlags_NoMenus | ImPlotFlags_NoLegend)) {
ImPlot::SetupAxes(nullptr, nullptr, ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight,
ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight | ImPlotAxisFlags_AutoFit | ImPlotAxisFlags_RangeFit);
ImPlot::SetupAxisLimitsConstraints(ImAxis_X1, preview_x_min, preview_x_max);
ImPlot::SetupAxisLimits(ImAxis_X1, preview_x_min, preview_x_max, ImPlotCond_Once);
ImPlot::SetupAxisFormat(ImAxis_X1, "%.1f");
ImPlot::SetupAxisFormat(ImAxis_Y1, "%.6g");
ImPlotSpec spec;
spec.LineColor = color_rgb(35, 107, 180);
spec.LineWeight = 2.0f;
ImPlot::PlotLine("##custom_preview_line", plot_xs.data(), plot_ys.data(), static_cast<int>(plot_xs.size()), spec);
ImPlot::EndPlot();
}
} else {
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 72.0f);
ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(116, 124, 133));
ImGui::TextWrapped("Choose an input timeseries or click Preview to evaluate the custom result.");
ImGui::PopStyleColor();
}
}
std::string custom_series_name_status(const Pane &pane, std::string_view name) {
const std::string trimmed = util::strip(std::string(name));
if (trimmed.empty()) return "name required";
if (!trimmed.empty() && trimmed.front() == '/') {
return "cannot start with /";
}
for (const Curve &curve : pane.curves) {
if (curve.runtime_only && curve.name == trimmed) return "updates existing curve";
}
return "new curve";
}
const CustomSeriesTemplate &selected_custom_series_template(const CustomSeriesEditorState &editor) {
const auto &templates = custom_series_templates();
return templates[static_cast<size_t>(std::clamp(editor.selected_template, 0, static_cast<int>(templates.size()) - 1))];
}
bool custom_series_template_ready(const CustomSeriesEditorState &editor) {
const CustomSeriesTemplate &templ = selected_custom_series_template(editor);
return !editor.linked_source.empty()
&& static_cast<int>(editor.additional_sources.size()) >= templ.required_additional_sources;
}
bool prepare_custom_series_spec(CustomSeriesEditorState *editor,
UiState *state,
bool require_name,
CustomPythonSeries *out_spec) {
editor->name = util::strip(editor->name);
editor->linked_source = util::strip(editor->linked_source);
for (std::string &path : editor->additional_sources) {
path = util::strip(path);
}
editor->additional_sources.erase(
std::remove_if(editor->additional_sources.begin(), editor->additional_sources.end(),
[&](const std::string &path) { return path.empty() || path == editor->linked_source; }),
editor->additional_sources.end());
if (require_name && editor->name.empty()) {
state->error_text = "Custom series name is required.";
state->open_error_popup = true;
return false;
}
if (require_name && !editor->name.empty() && editor->name.front() == '/') {
state->error_text = "Custom series names may not start with '/'.";
state->open_error_popup = true;
return false;
}
*out_spec = CustomPythonSeries{
.linked_source = editor->linked_source,
.additional_sources = editor->additional_sources,
.globals_code = editor->globals_code,
.function_code = editor->function_code,
};
return true;
}
bool preview_custom_series_editor(AppSession *session, UiState *state) {
CustomSeriesEditorState &editor = state->custom_series;
const CustomSeriesTemplate &templ = selected_custom_series_template(editor);
if (editor.linked_source.empty()) {
state->error_text = "Choose an input timeseries before previewing.";
state->open_error_popup = true;
state->status_text = "Custom series preview failed";
return false;
}
if (static_cast<int>(editor.additional_sources.size()) < templ.required_additional_sources) {
state->error_text = templ.requirement_text;
state->open_error_popup = true;
state->status_text = "Custom series preview failed";
return false;
}
CustomPythonSeries spec;
if (!prepare_custom_series_spec(&editor, state, false, &spec)) return false;
try {
PythonEvalResult result = evaluate_custom_python_series(*session, spec);
editor.preview_label = editor.name.empty() ? "Result preview" : editor.name;
editor.preview_xs = std::move(result.xs);
editor.preview_ys = std::move(result.ys);
editor.preview_is_result = true;
state->status_text = "Previewed custom series";
return true;
} catch (const std::exception &err) {
state->error_text = err.what();
state->open_error_popup = true;
state->status_text = "Custom series preview failed";
return false;
}
}
bool apply_custom_series_editor(AppSession *session, UiState *state) {
WorkspaceTab *tab = app_active_tab(&session->layout, *state);
TabUiState *tab_state = app_active_tab_state(state);
if (tab == nullptr || tab_state == nullptr) {
state->status_text = "No active pane";
return false;
}
if (tab_state->active_pane_index < 0 || tab_state->active_pane_index >= static_cast<int>(tab->panes.size())) {
state->status_text = "No active pane";
return false;
}
CustomSeriesEditorState &editor = state->custom_series;
CustomPythonSeries spec;
if (!prepare_custom_series_spec(&editor, state, true, &spec)) return false;
try {
PythonEvalResult result = evaluate_custom_python_series(*session, spec);
const SketchLayout before_layout = session->layout;
Pane &pane = tab->panes[static_cast<size_t>(tab_state->active_pane_index)];
editor.preview_label = editor.name;
editor.preview_xs = result.xs;
editor.preview_ys = result.ys;
editor.preview_is_result = true;
const bool inserted = upsert_custom_curve_in_pane(tab,
tab_state->active_pane_index,
make_custom_curve(pane, editor.name, spec, std::move(result)));
state->undo.push(before_layout);
state->status_text = inserted ? "Created custom series " + editor.name
: "Updated custom series " + editor.name;
return true;
} catch (const std::exception &err) {
state->error_text = err.what();
state->open_error_popup = true;
state->status_text = "Custom series failed";
return false;
}
}
} // namespace
void open_custom_series_editor(UiState *state, const std::string &preferred_source) {
CustomSeriesEditorState &editor = state->custom_series;
if (!editor.open && editor.name.empty() && editor.linked_source.empty() && editor.function_code == "return value") {
editor.focus_name = true;
}
if (editor.linked_source.empty() && !preferred_source.empty()) {
editor.linked_source = preferred_source;
}
editor.open = true;
editor.request_select = true;
}
std::string preferred_custom_series_source(const Pane &pane) {
for (const Curve &curve : pane.curves) {
if (!curve.name.empty() && curve.name.front() == '/') {
return curve.name;
}
if (curve.custom_python.has_value() && !curve.custom_python->linked_source.empty()) {
return curve.custom_python->linked_source;
}
}
return {};
}
void refresh_all_custom_curves(AppSession *session, UiState *state) {
for (WorkspaceTab &tab : session->layout.tabs) {
for (Pane &pane : tab.panes) {
for (Curve &curve : pane.curves) {
refresh_custom_curve_samples(session, state, &curve);
}
}
}
}
void draw_editor_source_panel(UiState *state, CustomSeriesEditorState &editor) {
ImGui::TextWrapped("Input timeseries. Provides arguments time and value:");
ImGui::SetNextItemWidth(-FLT_MIN);
input_text_string("##custom_linked_source", &editor.linked_source, ImGuiInputTextFlags_ReadOnly);
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload *payload = ImGui::AcceptDragDropPayload("JOTP_BROWSER_PATH")) {
editor.linked_source = static_cast<const char *>(payload->Data);
editor.additional_sources.erase(
std::remove(editor.additional_sources.begin(), editor.additional_sources.end(), editor.linked_source),
editor.additional_sources.end());
editor.preview_is_result = false;
}
ImGui::EndDragDropTarget();
}
if (ImGui::Button("Use Selected", ImVec2(120.0f, 0.0f)) && !state->selected_browser_path.empty()) {
editor.linked_source = state->selected_browser_path;
editor.additional_sources.erase(
std::remove(editor.additional_sources.begin(), editor.additional_sources.end(), editor.linked_source),
editor.additional_sources.end());
editor.preview_is_result = false;
}
ImGui::SameLine();
if (ImGui::Button("Clear", ImVec2(120.0f, 0.0f))) {
editor.linked_source.clear();
editor.preview_is_result = false;
}
ImGui::Spacing();
ImGui::TextUnformatted("Additional source timeseries:");
ImGui::SameLine();
const CustomSeriesTemplate &tmpl = selected_custom_series_template(editor);
if (tmpl.required_additional_sources > 0) {
const bool ready = static_cast<int>(editor.additional_sources.size()) >= tmpl.required_additional_sources;
ImGui::TextColored(ready ? color_rgb(58, 126, 73) : color_rgb(180, 122, 44), "%s", tmpl.requirement_text);
}
ImGui::SameLine();
ImGui::BeginDisabled(editor.selected_additional_source < 0
|| editor.selected_additional_source >= static_cast<int>(editor.additional_sources.size()));
if (ImGui::Button("Remove Selected", ImVec2(140.0f, 0.0f))
&& editor.selected_additional_source >= 0
&& editor.selected_additional_source < static_cast<int>(editor.additional_sources.size())) {
editor.additional_sources.erase(editor.additional_sources.begin()
+ static_cast<std::ptrdiff_t>(editor.selected_additional_source));
editor.selected_additional_source = editor.additional_sources.empty()
? -1 : std::clamp(editor.selected_additional_source, 0, static_cast<int>(editor.additional_sources.size()) - 1);
editor.preview_is_result = false;
}
ImGui::EndDisabled();
if (ImGui::BeginChild("##custom_additional_sources", ImVec2(0.0f, 156.0f), true)) {
if (ImGui::BeginTable("##custom_additional_table", 2,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) {
ImGui::TableSetupColumn("id", ImGuiTableColumnFlags_WidthFixed, 42.0f);
ImGui::TableSetupColumn("path", ImGuiTableColumnFlags_WidthStretch);
for (size_t i = 0; i < editor.additional_sources.size(); ++i) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::Text("v%zu", i + 1);
ImGui::TableNextColumn();
if (ImGui::Selectable(editor.additional_sources[i].c_str(),
editor.selected_additional_source == static_cast<int>(i),
ImGuiSelectableFlags_SpanAllColumns)) {
editor.selected_additional_source = static_cast<int>(i);
}
}
ImGui::EndTable();
}
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload *payload = ImGui::AcceptDragDropPayload("JOTP_BROWSER_PATH")) {
if (add_additional_source(&editor, static_cast<const char *>(payload->Data)))
editor.preview_is_result = false;
}
ImGui::EndDragDropTarget();
}
}
ImGui::EndChild();
if (ImGui::Button("Add Selected", ImVec2(120.0f, 0.0f))) {
for (const std::string &path : state->selected_browser_paths) {
if (add_additional_source(&editor, path)) editor.preview_is_result = false;
}
}
ImGui::Spacing();
ImGui::SeparatorText("Function library");
const auto &templates = custom_series_templates();
if (ImGui::BeginChild("##custom_series_template_list", ImVec2(0.0f, 132.0f), true)) {
for (size_t i = 0; i < templates.size(); ++i) {
if (ImGui::Selectable(templates[i].name, editor.selected_template == static_cast<int>(i),
ImGuiSelectableFlags_AllowDoubleClick)) {
editor.selected_template = static_cast<int>(i);
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
editor.globals_code = templates[i].globals_code;
editor.function_code = templates[i].function_code;
editor.preview_is_result = false;
}
}
}
}
ImGui::EndChild();
if (ImGui::Button("Use Selected Example")) {
const auto &sel = selected_custom_series_template(editor);
editor.globals_code = sel.globals_code;
editor.function_code = sel.function_code;
editor.preview_is_result = false;
}
ImGui::Spacing();
ImGui::TextDisabled("Preview");
ImGui::BeginChild("##custom_series_template_preview", ImVec2(0.0f, 0.0f), true);
ImGui::TextUnformatted(selected_custom_series_template(editor).preview_text);
ImGui::EndChild();
}
void draw_editor_code_panel(CustomSeriesEditorState &editor, const Pane *active_pane) {
const std::string name_status = active_pane != nullptr ? custom_series_name_status(*active_pane, editor.name) : "no active pane";
ImGui::TextUnformatted("New name:");
ImGui::SameLine();
const bool name_error = name_status == "name required" || name_status == "cannot start with /";
ImGui::TextColored(name_error ? color_rgb(200, 72, 64) : color_rgb(58, 126, 73), "%s", name_status.c_str());
if (editor.focus_name) { ImGui::SetKeyboardFocusHere(); editor.focus_name = false; }
ImGui::SetNextItemWidth(-FLT_MIN);
input_text_string("##custom_series_name", &editor.name, ImGuiInputTextFlags_AutoSelectAll);
ImGui::Spacing();
ImGui::SeparatorText("Global variables");
ImGui::SameLine();
if (ImGui::SmallButton("Help")) editor.open_help = true;
const float globals_h = std::max(96.0f, ImGui::GetContentRegionAvail().y * 0.28f);
if (input_text_multiline_string("##custom_series_globals", &editor.globals_code,
ImVec2(-FLT_MIN, globals_h), ImGuiInputTextFlags_AllowTabInput))
editor.preview_is_result = false;
ImGui::Spacing();
ImGui::TextUnformatted("def calc(time, value):");
const float func_h = std::max(180.0f, ImGui::GetContentRegionAvail().y - 16.0f);
if (input_text_multiline_string("##custom_series_function", &editor.function_code,
ImVec2(-FLT_MIN, func_h), ImGuiInputTextFlags_AllowTabInput))
editor.preview_is_result = false;
}
void draw_custom_series_editor(AppSession *session, UiState *state) {
CustomSeriesEditorState &editor = state->custom_series;
if (!editor.open) return;
WorkspaceTab *tab = app_active_tab(&session->layout, *state);
TabUiState *tab_state = app_active_tab_state(state);
Pane *active_pane = (tab && tab_state && tab_state->active_pane_index >= 0
&& tab_state->active_pane_index < static_cast<int>(tab->panes.size()))
? &tab->panes[static_cast<size_t>(tab_state->active_pane_index)] : nullptr;
if (editor.focus_name && active_pane && editor.name.empty())
editor.name = next_custom_curve_name(*active_pane);
draw_custom_series_help_popup(&editor);
if (ImGui::BeginTabBar("##custom_series_tabs")) {
if (ImGui::BeginTabItem("Single Function")) {
const float footer_height = ImGui::GetFrameHeightWithSpacing() * 2.0f + 10.0f;
if (ImGui::BeginChild("##custom_series_body",
ImVec2(0.0f, std::max(1.0f, ImGui::GetContentRegionAvail().y - footer_height)), false)) {
if (ImGui::BeginChild("##custom_series_preview_child",
ImVec2(0.0f, std::max(200.0f, ImGui::GetContentRegionAvail().y * 0.28f)), true))
draw_custom_series_preview(*session, &editor);
ImGui::EndChild();
ImGui::Spacing();
if (ImGui::BeginTable("##custom_series_editor_table", 2,
ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchProp,
ImVec2(0.0f, std::max(1.0f, ImGui::GetContentRegionAvail().y)))) {
ImGui::TableSetupColumn("left", ImGuiTableColumnFlags_WidthFixed, 320.0f);
ImGui::TableSetupColumn("right", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableNextColumn();
if (ImGui::BeginChild("##custom_series_left", ImVec2(0.0f, 0.0f), false))
draw_editor_source_panel(state, editor);
ImGui::EndChild();
ImGui::TableNextColumn();
if (ImGui::BeginChild("##custom_series_right", ImVec2(0.0f, 0.0f), false))
draw_editor_code_panel(editor, active_pane);
ImGui::EndChild();
ImGui::EndTable();
}
}
ImGui::EndChild();
ImGui::Spacing();
if (ImGui::Button("New", ImVec2(120.0f, 0.0f))) {
reset_custom_series_editor(&editor);
if (!state->selected_browser_path.empty()) editor.linked_source = state->selected_browser_path;
editor.open = true;
editor.focus_name = true;
}
ImGui::SameLine();
ImGui::BeginDisabled(!custom_series_template_ready(editor));
if (ImGui::Button("Preview Result", ImVec2(120.0f, 0.0f)))
preview_custom_series_editor(session, state);
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled) && !custom_series_template_ready(editor)) {
if (editor.linked_source.empty()) ImGui::SetTooltip("Choose an input timeseries first.");
else ImGui::SetTooltip("%s", selected_custom_series_template(editor).requirement_text);
}
ImGui::SameLine();
if (ImGui::Button("Apply", ImVec2(120.0f, 0.0f))) apply_custom_series_editor(session, state);
ImGui::SameLine();
if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { editor.open = false; editor.request_select = false; }
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
}
+400
View File
@@ -0,0 +1,400 @@
#pragma once
#include "common/util.h"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <optional>
#include <regex>
#include <sstream>
#include <stdexcept>
#include <string>
#include <tuple>
#include <unordered_map>
#include <vector>
namespace dbc {
struct ValueDescriptionEntry {
double value = 0.0;
std::string text;
};
struct Signal {
enum class Type {
Normal = 0,
Multiplexed,
Multiplexor,
};
Type type = Type::Normal;
std::string name;
int start_bit = 0;
int msb = 0;
int lsb = 0;
int size = 0;
double factor = 1.0;
double offset = 0.0;
double min = 0.0;
double max = 0.0;
bool is_signed = false;
bool is_little_endian = false;
std::string unit;
std::string comment;
std::string receiver_name;
int multiplex_value = 0;
int multiplexor_index = -1;
std::vector<ValueDescriptionEntry> value_descriptions;
};
struct Message {
uint32_t address = 0;
std::string name;
uint32_t size = 0;
std::string comment;
std::string transmitter;
std::vector<Signal> signals;
int multiplexor_index = -1;
const std::vector<Signal> &getSignals() const { return signals; }
};
class Database {
public:
Database() = default;
explicit Database(const std::filesystem::path &path);
static Database fromContent(const std::string &content, const std::string &filename = "<dbc>");
const Message *message(uint32_t address) const;
const std::unordered_map<uint32_t, Message> &messages() const { return messages_; }
std::vector<std::string> enumNames(const Signal &signal) const;
private:
void parse(const std::string &content, const std::string &filename);
void parseBo(const std::string &line, int line_number, Message **current_message);
void parseSg(const std::string &line, int line_number, Message *current_message);
void parseVal(const std::string &line, int line_number);
void parseCmBo(const std::string &line, int line_number);
void parseCmSg(const std::string &line, int line_number);
void finalize();
std::string filename_;
std::unordered_map<uint32_t, Message> messages_;
};
void updateMsbLsb(Signal *signal);
double rawSignalValue(const Signal &signal, const uint8_t *data, size_t data_size);
std::optional<double> signalValue(const Signal &signal, const Message &message, const uint8_t *data, size_t data_size);
namespace {
std::string unescape_dbc_string(std::string text) {
size_t pos = 0;
while ((pos = text.find("\\\"", pos)) != std::string::npos) {
text.replace(pos, 2, "\"");
++pos;
}
return text;
}
int flip_bit_pos(int start_bit) {
return 8 * (start_bit / 8) + 7 - start_bit % 8;
}
std::string read_multiline_statement(std::istream &stream, std::string statement, int *line_number) {
static const std::regex statement_end(R"(\"\s*;\s*$)");
while (true) {
const std::string trimmed = util::strip(statement);
if (std::regex_search(trimmed, statement_end)) {
return trimmed;
}
std::string next_line;
if (!std::getline(stream, next_line)) {
return trimmed;
}
statement += "\n";
statement += next_line;
++(*line_number);
}
}
} // namespace
inline void updateMsbLsb(Signal *signal) {
if (signal->is_little_endian) {
signal->lsb = signal->start_bit;
signal->msb = signal->start_bit + signal->size - 1;
} else {
signal->lsb = flip_bit_pos(flip_bit_pos(signal->start_bit) + signal->size - 1);
signal->msb = signal->start_bit;
}
}
inline double rawSignalValue(const Signal &signal, const uint8_t *data, size_t data_size) {
const int msb_byte = signal.msb / 8;
if (msb_byte >= static_cast<int>(data_size)) return 0.0;
const int lsb_byte = signal.lsb / 8;
uint64_t val = 0;
if (msb_byte == lsb_byte) {
val = (data[msb_byte] >> (signal.lsb & 7)) & ((1ULL << signal.size) - 1);
} else {
int bits = signal.size;
int i = msb_byte;
const int step = signal.is_little_endian ? -1 : 1;
while (i >= 0 && i < static_cast<int>(data_size) && bits > 0) {
const int msb = (i == msb_byte) ? signal.msb & 7 : 7;
const int lsb = (i == lsb_byte) ? signal.lsb & 7 : 0;
const int nbits = msb - lsb + 1;
val = (val << nbits) | ((data[i] >> lsb) & ((1ULL << nbits) - 1));
bits -= nbits;
i += step;
}
}
if (signal.is_signed && (val & (1ULL << (signal.size - 1)))) {
val |= ~((1ULL << signal.size) - 1);
}
return static_cast<int64_t>(val) * signal.factor + signal.offset;
}
[[noreturn]] inline void parse_error(const std::string &filename, int line_number, const std::string &message, const std::string &line) {
std::ostringstream out;
out << "[" << filename << ":" << line_number << "] " << message << ": " << line;
throw std::runtime_error(out.str());
}
inline Database::Database(const std::filesystem::path &path) {
const std::string content = util::read_file(path.string());
if (content.empty() && !std::filesystem::exists(path)) {
throw std::runtime_error("Failed to open DBC " + path.string());
}
parse(content, path.filename().string());
}
inline Database Database::fromContent(const std::string &content, const std::string &filename) {
Database db;
db.parse(content, filename);
return db;
}
inline const Message *Database::message(uint32_t address) const {
auto it = messages_.find(address);
return it == messages_.end() ? nullptr : &it->second;
}
inline std::vector<std::string> Database::enumNames(const Signal &signal) const {
if (signal.value_descriptions.empty()) return {};
int max_index = -1;
for (const auto &entry : signal.value_descriptions) {
const double rounded = std::round(entry.value);
if (std::abs(entry.value - rounded) > 1e-6 || rounded < 0.0 || rounded > 512.0) return {};
max_index = std::max(max_index, static_cast<int>(rounded));
}
if (max_index < 0) return {};
std::vector<std::string> names(static_cast<size_t>(max_index + 1));
for (const auto &entry : signal.value_descriptions) {
names[static_cast<size_t>(std::llround(entry.value))] = entry.text;
}
return names;
}
inline void Database::parse(const std::string &content, const std::string &filename) {
filename_ = filename;
messages_.clear();
std::istringstream stream(content);
std::string raw_line;
Message *current_message = nullptr;
int line_number = 0;
while (std::getline(stream, raw_line)) {
++line_number;
std::string line = util::strip(raw_line);
if (line.empty()) continue;
if (util::starts_with(line, "BO_ ")) {
parseBo(line, line_number, &current_message);
} else if (util::starts_with(line, "SG_ ")) {
if (current_message == nullptr) {
parse_error(filename, line_number, "Signal without current message", line);
}
parseSg(line, line_number, current_message);
} else if (util::starts_with(line, "VAL_ ")) {
parseVal(line, line_number);
} else if (util::starts_with(line, "CM_ BO_")) {
parseCmBo(read_multiline_statement(stream, raw_line, &line_number), line_number);
} else if (util::starts_with(line, "CM_ SG_")) {
parseCmSg(read_multiline_statement(stream, raw_line, &line_number), line_number);
}
}
finalize();
}
inline void Database::parseBo(const std::string &line, int line_number, Message **current_message) {
static const std::regex pattern(R"(^BO_\s+(\w+)\s+(\w+)\s*:\s*(\w+)\s+(\w+)\s*$)");
std::smatch match;
if (!std::regex_match(line, match, pattern)) {
parse_error("<dbc>", line_number, "Invalid BO_ line format", line);
}
uint32_t address = static_cast<uint32_t>(std::stoul(match[1].str(), nullptr, 0));
if (messages_.find(address) != messages_.end()) {
parse_error(filename_, line_number, "Duplicate message address", line);
}
Message &message = messages_[address];
message.address = address;
message.name = match[2].str();
message.size = static_cast<uint32_t>(std::stoul(match[3].str(), nullptr, 0));
message.transmitter = match[4].str();
message.signals.clear();
message.multiplexor_index = -1;
*current_message = &message;
}
inline void Database::parseSg(const std::string &line, int line_number, Message *current_message) {
static const std::regex multiplex_pattern(R"(^SG_\s+(\w+)\s+(\w+)\s*:\s*(\d+)\|(\d+)@(\d)([+-])\s+\(([0-9.+\-eE]+),([0-9.+\-eE]+)\)\s+\[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\]\s+\"(.*)\"\s+(.*)$)");
static const std::regex normal_pattern(R"(^SG_\s+(\w+)\s*:\s*(\d+)\|(\d+)@(\d)([+-])\s+\(([0-9.+\-eE]+),([0-9.+\-eE]+)\)\s+\[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\]\s+\"(.*)\"\s+(.*)$)");
std::smatch match;
Signal signal;
int offset = 0;
if (std::regex_match(line, match, normal_pattern)) {
offset = 0;
} else if (std::regex_match(line, match, multiplex_pattern)) {
offset = 1;
const std::string indicator = match[2].str();
if (indicator == "M") {
if (std::any_of(current_message->signals.begin(), current_message->signals.end(), [](const Signal &existing) {
return existing.type == Signal::Type::Multiplexor;
})) {
parse_error(filename_, line_number, "Multiple multiplexor", line);
}
signal.type = Signal::Type::Multiplexor;
} else if (!indicator.empty() && indicator.front() == 'm') {
signal.type = Signal::Type::Multiplexed;
signal.multiplex_value = std::stoi(indicator.substr(1));
} else {
parse_error("<dbc>", line_number, "Invalid multiplex indicator", line);
}
} else {
parse_error("<dbc>", line_number, "Invalid SG_ line format", line);
}
signal.name = match[1].str();
if (std::any_of(current_message->signals.begin(), current_message->signals.end(), [&](const Signal &existing) {
return existing.name == signal.name;
})) {
parse_error(filename_, line_number, "Duplicate signal name", line);
}
signal.start_bit = std::stoi(match[2 + offset].str());
signal.size = std::stoi(match[3 + offset].str());
signal.is_little_endian = match[4 + offset].str() == "1";
signal.is_signed = match[5 + offset].str() == "-";
signal.factor = std::stod(match[6 + offset].str());
signal.offset = std::stod(match[7 + offset].str());
signal.min = std::stod(match[8 + offset].str());
signal.max = std::stod(match[9 + offset].str());
signal.unit = match[10 + offset].str();
signal.receiver_name = util::strip(match[11 + offset].str());
updateMsbLsb(&signal);
current_message->signals.push_back(std::move(signal));
}
inline void Database::parseVal(const std::string &line, int line_number) {
static const std::regex prefix(R"(^VAL_\s+(\w+)\s+(\w+)\s+(.*);$)");
std::smatch match;
if (!std::regex_match(line, match, prefix)) {
parse_error("<dbc>", line_number, "Invalid VAL_ line format", line);
}
const uint32_t address = static_cast<uint32_t>(std::stoul(match[1].str(), nullptr, 0));
auto msg_it = messages_.find(address);
if (msg_it == messages_.end()) {
return;
}
auto sig_it = std::find_if(msg_it->second.signals.begin(), msg_it->second.signals.end(), [&](const Signal &signal) {
return signal.name == match[2].str();
});
if (sig_it == msg_it->second.signals.end()) {
return;
}
static const std::regex entry_pattern(R"(([+-]?\d+(?:\.\d+)?)\s+\"((?:[^\"\\]|\\.)*)\")");
const std::string defs = match[3].str();
for (std::sregex_iterator it(defs.begin(), defs.end(), entry_pattern), end; it != end; ++it) {
sig_it->value_descriptions.push_back(ValueDescriptionEntry{
.value = std::stod((*it)[1].str()),
.text = (*it)[2].str(),
});
}
}
inline void Database::parseCmBo(const std::string &line, int line_number) {
static const std::regex pattern(R"(^CM_\s+BO_\s*(\w+)\s*\"((?:[^\"\\]|\\.|[\r\n])*)\"\s*;\s*$)");
std::smatch match;
if (!std::regex_match(line, match, pattern)) {
parse_error(filename_, line_number, "Invalid message comment format", line);
}
const uint32_t address = static_cast<uint32_t>(std::stoul(match[1].str(), nullptr, 0));
auto it = messages_.find(address);
if (it != messages_.end()) {
it->second.comment = unescape_dbc_string(match[2].str());
}
}
inline void Database::parseCmSg(const std::string &line, int line_number) {
static const std::regex pattern(R"(^CM_\s+SG_\s*(\w+)\s*(\w+)\s*\"((?:[^\"\\]|\\.|[\r\n])*)\"\s*;\s*$)");
std::smatch match;
if (!std::regex_match(line, match, pattern)) {
parse_error(filename_, line_number, "Invalid signal comment format", line);
}
const uint32_t address = static_cast<uint32_t>(std::stoul(match[1].str(), nullptr, 0));
auto msg_it = messages_.find(address);
if (msg_it == messages_.end()) return;
auto sig_it = std::find_if(msg_it->second.signals.begin(), msg_it->second.signals.end(), [&](const Signal &signal) {
return signal.name == match[2].str();
});
if (sig_it != msg_it->second.signals.end()) {
sig_it->comment = unescape_dbc_string(match[3].str());
}
}
inline void Database::finalize() {
for (auto &[_, message] : messages_) {
std::sort(message.signals.begin(), message.signals.end(), [](const Signal &left, const Signal &right) {
return std::tie(right.type, left.multiplex_value, left.start_bit, left.name)
< std::tie(left.type, right.multiplex_value, right.start_bit, right.name);
});
message.multiplexor_index = -1;
for (size_t i = 0; i < message.signals.size(); ++i) {
if (message.signals[i].type == Signal::Type::Multiplexor) {
message.multiplexor_index = static_cast<int>(i);
break;
}
}
for (Signal &signal : message.signals) {
signal.multiplexor_index = signal.type == Signal::Type::Multiplexed ? message.multiplexor_index : -1;
if (signal.type == Signal::Type::Multiplexed && signal.multiplexor_index < 0) {
signal.type = Signal::Type::Normal;
signal.multiplex_value = 0;
}
}
}
}
inline std::optional<double> signalValue(const Signal &signal, const Message &message, const uint8_t *data, size_t data_size) {
if (signal.multiplexor_index >= 0) {
const Signal &multiplexor = message.signals[static_cast<size_t>(signal.multiplexor_index)];
const double mux_value = rawSignalValue(multiplexor, data, data_size);
if (std::llround(mux_value) != signal.multiplex_value) return std::nullopt;
}
return rawSignalValue(signal, data, data_size);
}
} // namespace dbc
@@ -0,0 +1,2 @@
*
!.gitignore
+24
View File
@@ -0,0 +1,24 @@
#include "tools/jotpluggler/app.h"
#include "tools/jotpluggler/common.h"
#include <cmath>
void icon_add_font(float size, bool merge, const ImFont *base_font) {
const std::filesystem::path ttf = repo_root() / "third_party" / "bootstrap" / "bootstrap-icons.ttf";
ImGuiIO &io = ImGui::GetIO();
ImFontConfig config;
config.MergeMode = merge;
config.GlyphMinAdvanceX = size;
if (base_font != nullptr) {
ImFontBaked *baked = const_cast<ImFont *>(base_font)->GetFontBaked(size);
const float base_center = baked != nullptr ? (baked->Ascent + baked->Descent) * 0.5f : size * 0.5f;
config.GlyphOffset.y = std::round(size * 0.5f - base_center);
}
static const ImWchar ranges[] = {0xF000, 0xF8FF, 0};
io.Fonts->AddFontFromFileTTF(ttf.c_str(), size, &config, ranges);
}
bool icon_menu_item(const char *glyph, const char *label, const char *shortcut, bool selected, bool enabled) {
assert(glyph != nullptr && glyph[0] != '\0');
return ImGui::MenuItem(util::string_format("%s %s", glyph, label).c_str(), shortcut, selected, enabled);
}
+166
View File
@@ -0,0 +1,166 @@
#pragma once
#include "tools/jotpluggler/common.h"
#include "tools/jotpluggler/map.h"
#include <filesystem>
#include <functional>
#include <optional>
struct GLFWwindow;
enum class PaneDropZone {
Center,
Left,
Right,
Top,
Bottom,
};
enum class PaneMenuActionKind {
None,
OpenAxisLimits,
OpenCustomSeries,
SplitLeft,
SplitRight,
SplitTop,
SplitBottom,
ResetView,
ResetHorizontal,
ResetVertical,
Clear,
Close,
};
struct PaneMenuAction {
PaneMenuActionKind kind = PaneMenuActionKind::None;
int pane_index = -1;
};
struct PaneCurveDragPayload {
int tab_index = -1;
int pane_index = -1;
int curve_index = -1;
};
struct PaneDropAction {
PaneDropZone zone = PaneDropZone::Center;
int target_pane_index = -1;
bool from_browser = false;
std::vector<std::string> browser_paths;
std::string special_item_id;
PaneCurveDragPayload curve_ref;
};
inline constexpr float SIDEBAR_WIDTH = 320.0f;
inline constexpr float SIDEBAR_MIN_WIDTH = 220.0f;
inline constexpr float SIDEBAR_MAX_WIDTH = 520.0f;
inline constexpr float TIMELINE_BAR_HEIGHT = 14.0f;
inline constexpr float STATUS_BAR_HEIGHT = 52.0f;
inline constexpr double MIN_HORIZONTAL_ZOOM_SECONDS = 2.0;
struct UiMetrics {
float width = 0.0f;
float height = 0.0f;
float top_offset = 0.0f;
float sidebar_width = SIDEBAR_WIDTH;
float content_x = 0.0f;
float content_y = 0.0f;
float content_w = 0.0f;
float content_h = 0.0f;
float status_bar_y = 0.0f;
};
std::filesystem::path resolve_layout_path(const std::string &layout_arg);
std::filesystem::path autosave_path_for_layout(const std::filesystem::path &layout_path);
std::vector<std::string> available_layout_names();
SketchLayout make_empty_layout();
void cancel_rename_tab(UiState *state);
void sync_ui_state(UiState *state, const SketchLayout &layout);
void sync_route_buffers(UiState *state, const AppSession &session);
void sync_stream_buffers(UiState *state, const AppSession &session);
void sync_layout_buffers(UiState *state, const AppSession &session);
void mark_all_docks_dirty(UiState *state);
void clear_layout_autosave(const AppSession &session);
bool autosave_layout(AppSession *session, UiState *state);
bool apply_axis_limits_editor(AppSession *session, UiState *state);
void open_axis_limits_editor(const AppSession &session, UiState *state, int pane_index);
void persist_shared_range_to_tab(WorkspaceTab *tab, const UiState &state);
void clear_pane_vertical_limits(Pane *pane);
void refresh_replaced_layout_ui(AppSession *session, UiState *state, bool mark_docks);
void start_new_layout(AppSession *session, UiState *state, const std::string &status_text = "New untitled layout");
void apply_dbc_override_change(AppSession *session, UiState *state, const std::string &dbc_override);
void app_push_bold_font();
void app_pop_bold_font();
void draw_vertical_splitter(const char *id, float height, float min_left, float max_left, float *left_width);
void draw_right_splitter(const char *id, float height, float min_right, float max_right, float *right_width);
bool draw_horizontal_splitter(const char *id, float width, float min_top, float max_top, float *top_height);
void draw_payload_bytes(std::string_view data, const std::string *prev_data = nullptr);
void draw_payload_preview_boxes(const char *id, std::string_view data, const std::string *prev_data, float max_width);
void draw_signal_sparkline(const AppSession &session,
const UiState &state,
std::string_view signal_path,
bool selected,
ImVec2 size = ImVec2(0.0f, 24.0f));
ImU32 mix_color(ImU32 a, ImU32 b, float t);
void draw_empty_panel(const char *title, const char *message);
UiMetrics compute_ui_metrics(const ImVec2 &size, float top_offset, float sidebar_width);
void draw_sidebar(AppSession *session, const UiMetrics &ui, UiState *state, bool show_camera_feed);
void draw_workspace(AppSession *session, const UiMetrics &ui, UiState *state);
void draw_pane_windows(AppSession *session, UiState *state);
// plot.cc
void draw_plot(const AppSession &session, Pane *pane, UiState *state);
bool draw_pane_close_button_overlay();
void draw_pane_frame_overlay();
std::optional<PaneMenuAction> draw_pane_context_menu(const WorkspaceTab &tab, int pane_index);
bool curve_has_samples(const AppSession &session, const Curve &curve);
bool curve_has_local_samples(const Curve &curve);
std::string app_curve_display_name(const Curve &curve);
bool mark_layout_dirty(AppSession *session, UiState *state);
const RouteSeries *app_find_route_series(const AppSession &session, const std::string &path);
void sync_camera_feeds(AppSession *session);
void apply_route_data(AppSession *session, UiState *state, RouteData route_data);
bool apply_undo(AppSession *session, UiState *state);
bool apply_redo(AppSession *session, UiState *state);
bool infer_stream_follow_state(const UiState &state, const AppSession &session);
void ensure_shared_range(UiState *state, const AppSession &session);
void clamp_shared_range(UiState *state, const AppSession &session);
void reset_shared_range(UiState *state, const AppSession &session);
void update_follow_range(UiState *state, const AppSession &session);
void advance_playback(UiState *state, const AppSession &session);
void step_tracker(UiState *state, double direction);
std::string dbc_combo_label(const AppSession &session);
const char *log_selector_name(LogSelector selector);
const char *log_selector_description(LogSelector selector);
std::string format_cache_bytes(uint64_t bytes);
MapCacheStats directory_cache_stats(const std::filesystem::path &root);
float draw_main_menu_bar(AppSession *session, UiState *state);
bool reset_layout(AppSession *session, UiState *state);
bool reload_layout(AppSession *session, UiState *state, const std::string &layout_arg);
bool save_layout(AppSession *session, UiState *state, const std::string &layout_path);
void rebuild_session_route_data(AppSession *session, UiState *state,
const RouteLoadProgressCallback &progress = {});
void stop_stream_session(AppSession *session, UiState *state, bool preserve_data = true);
bool start_stream_session(AppSession *session,
UiState *state,
const StreamSourceConfig &source,
double buffer_seconds,
bool preserve_existing_data = false);
void start_async_route_load(AppSession *session, UiState *state);
void poll_async_route_load(AppSession *session, UiState *state);
bool reload_session(AppSession *session, UiState *state, const std::string &route_name, const std::string &data_dir);
void draw_popups(AppSession *session, UiState *state);
void draw_status_bar(const AppSession &session, const UiMetrics &ui, UiState *state);
void draw_sidebar_resizer(const UiMetrics &ui, UiState *state);
void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch batch);
void render_frame(GLFWwindow *window, AppSession *session, UiState *state, const std::filesystem::path *capture_path);
+704
View File
@@ -0,0 +1,704 @@
#include "tools/jotpluggler/internal.h"
#include "system/hardware/hw.h"
#include <unistd.h>
namespace fs = std::filesystem;
namespace {
enum class ModalAction {
None,
Primary,
Secondary,
};
struct FindSignalMatch {
const std::string *path = nullptr;
int score = 0;
};
struct DbcEditorSource {
fs::path path;
DbcEditorState::SourceKind kind = DbcEditorState::SourceKind::None;
};
StreamSourceConfig stream_source_config_from_ui(const UiState &state) {
StreamSourceConfig source;
source.kind = state.stream_source_kind;
source.address = util::strip(state.stream_address_buffer);
if (source.kind == StreamSourceKind::CerealLocal) {
source.address = "127.0.0.1";
} else {
source.address = normalize_stream_address(std::move(source.address));
}
return source;
}
void open_queued_popup(bool &flag, const char *name) {
if (flag) {
ImGui::OpenPopup(name);
flag = false;
}
}
ModalAction draw_modal_action_row(const char *primary_label,
const char *secondary_label = "Cancel",
float width = 120.0f) {
if (ImGui::Button(primary_label, ImVec2(width, 0.0f))) {
return ModalAction::Primary;
}
ImGui::SameLine();
if (ImGui::Button(secondary_label, ImVec2(width, 0.0f))) {
return ModalAction::Secondary;
}
return ModalAction::None;
}
std::vector<FindSignalMatch> find_signal_matches(const AppSession &session, std::string_view query) {
std::vector<FindSignalMatch> matches;
if (query.empty()) {
return matches;
}
const std::string needle = lowercase_copy(query);
for (const std::string &path : session.route_data.paths) {
const std::string hay = lowercase_copy(path);
const size_t pos = hay.find(needle);
if (pos == std::string::npos) {
continue;
}
const size_t slash = path.find_last_of('/');
const std::string_view label = slash == std::string::npos ? std::string_view(path) : std::string_view(path).substr(slash + 1);
int score = static_cast<int>(pos * 8 + path.size());
if (lowercase_copy(label) == needle) score -= 60;
if (util::starts_with(hay, needle)) score -= 30;
matches.push_back({.path = &path, .score = score});
}
std::sort(matches.begin(), matches.end(), [](const FindSignalMatch &a, const FindSignalMatch &b) {
return std::tie(a.score, *a.path) < std::tie(b.score, *b.path);
});
if (matches.size() > 200) {
matches.resize(200);
}
return matches;
}
bool open_find_signal_result(UiState *state, const std::string &path) {
state->selected_browser_paths = {path};
state->selected_browser_path = path;
state->browser_selection_anchor = path;
state->status_text = "Selected signal " + path;
return true;
}
void draw_open_route_popup(AppSession *session, UiState *state) {
if (!ImGui::BeginPopupModal("Open Route", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
return;
}
ImGui::TextUnformatted("Load a route into the current layout.");
ImGui::Separator();
input_text_string("Route", &state->route_buffer);
input_text_string("Data Dir", &state->data_dir_buffer);
ImGui::Spacing();
switch (draw_modal_action_row("Load")) {
case ModalAction::Primary:
reload_session(session, state, state->route_buffer, state->data_dir_buffer);
ImGui::CloseCurrentPopup();
break;
case ModalAction::Secondary:
sync_route_buffers(state, *session);
ImGui::CloseCurrentPopup();
break;
case ModalAction::None:
break;
}
ImGui::EndPopup();
}
void draw_stream_popup(AppSession *session, UiState *state) {
if (!ImGui::BeginPopupModal("Live Stream", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
return;
}
ImGui::TextUnformatted("Connect to a live source.");
ImGui::Separator();
if (ImGui::RadioButton("Local (MSGQ)", state->stream_source_kind == StreamSourceKind::CerealLocal)) {
state->stream_source_kind = StreamSourceKind::CerealLocal;
}
if (ImGui::RadioButton("Remote (ZMQ)", state->stream_source_kind == StreamSourceKind::CerealRemote)) {
state->stream_source_kind = StreamSourceKind::CerealRemote;
}
if (state->stream_source_kind == StreamSourceKind::CerealRemote) {
input_text_string("Address", &state->stream_address_buffer);
}
ImGui::InputDouble("Buffer (seconds)", &state->stream_buffer_seconds, 0.0, 0.0, "%.0f");
ImGui::Spacing();
switch (draw_modal_action_row("Connect")) {
case ModalAction::Primary: {
const StreamSourceConfig source = stream_source_config_from_ui(*state);
if (start_stream_session(session, state, source, state->stream_buffer_seconds, false)) {
ImGui::CloseCurrentPopup();
}
break;
}
case ModalAction::Secondary:
sync_stream_buffers(state, *session);
ImGui::CloseCurrentPopup();
break;
case ModalAction::None:
break;
}
ImGui::EndPopup();
}
void draw_load_layout_popup(AppSession *session, UiState *state) {
if (!ImGui::BeginPopupModal("Load Layout", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
return;
}
ImGui::TextUnformatted("Load a JotPlugger JSON layout.");
ImGui::Separator();
input_text_string("Layout", &state->load_layout_buffer);
ImGui::Spacing();
switch (draw_modal_action_row("Load")) {
case ModalAction::Primary:
if (reload_layout(session, state, state->load_layout_buffer)) {
ImGui::CloseCurrentPopup();
}
break;
case ModalAction::Secondary:
sync_layout_buffers(state, *session);
ImGui::CloseCurrentPopup();
break;
case ModalAction::None:
break;
}
ImGui::EndPopup();
}
void draw_save_layout_popup(AppSession *session, UiState *state) {
if (!ImGui::BeginPopupModal("Save Layout", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
return;
}
ImGui::TextUnformatted("Save the current workspace as a JotPlugger JSON layout.");
ImGui::Separator();
input_text_string("Layout", &state->save_layout_buffer);
ImGui::Spacing();
switch (draw_modal_action_row("Save")) {
case ModalAction::Primary:
if (save_layout(session, state, state->save_layout_buffer)) {
ImGui::CloseCurrentPopup();
}
break;
case ModalAction::Secondary:
sync_layout_buffers(state, *session);
ImGui::CloseCurrentPopup();
break;
case ModalAction::None:
break;
}
ImGui::EndPopup();
}
void draw_preferences_popup(AppSession *session, UiState *state) {
if (!ImGui::BeginPopupModal("Preferences", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
return;
}
if (session->map_data) {
const MapCacheStats map_cache = session->map_data->cacheStats();
const MapCacheStats download_cache = directory_cache_stats(Path::download_cache_root());
ImGui::TextUnformatted("Map");
ImGui::Separator();
ImGui::Text("Map cache: %s in %zu file%s",
format_cache_bytes(map_cache.bytes).c_str(),
map_cache.files,
map_cache.files == 1 ? "" : "s");
if (ImGui::Button("Clear Map Cache", ImVec2(120.0f, 0.0f))) {
session->map_data->clearCache();
state->status_text = "Cleared map cache";
}
ImGui::Spacing();
ImGui::TextUnformatted("comma Download Cache");
ImGui::Separator();
ImGui::Text("Download cache: %s in %zu file%s",
format_cache_bytes(download_cache.bytes).c_str(),
download_cache.files,
download_cache.files == 1 ? "" : "s");
ImGui::TextDisabled("%s", Path::download_cache_root().c_str());
ImGui::Spacing();
}
if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
void draw_find_signal_popup(AppSession *session, UiState *state) {
if (!ImGui::BeginPopupModal("Find Signal", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
return;
}
ImGui::TextUnformatted("Search decoded signals across the loaded route.");
ImGui::Separator();
ImGui::SetNextItemWidth(560.0f);
input_text_with_hint_string("##find_signal_query", "Search signal path or name...", &state->find_signal_buffer);
if (ImGui::IsWindowAppearing()) {
ImGui::SetKeyboardFocusHere(-1);
}
const std::vector<FindSignalMatch> matches = find_signal_matches(*session, state->find_signal_buffer);
ImGui::Spacing();
ImGui::TextDisabled("%zu match%s", matches.size(), matches.size() == 1 ? "" : "es");
if (ImGui::BeginChild("##find_signal_results", ImVec2(760.0f, 360.0f), true)) {
for (const FindSignalMatch &match : matches) {
const std::string &path = *match.path;
const size_t slash = path.find_last_of('/');
const std::string_view label = slash == std::string::npos ? std::string_view(path) : std::string_view(path).substr(slash + 1);
if (ImGui::Selectable((std::string(label) + "##" + path).c_str(), false, ImGuiSelectableFlags_SpanAllColumns)) {
if (open_find_signal_result(state, path)) {
ImGui::CloseCurrentPopup();
}
}
ImGui::SameLine(280.0f);
ImGui::TextDisabled("%s", path.c_str());
}
}
ImGui::EndChild();
ImGui::Spacing();
if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
std::string default_dbc_template() {
return "VERSION \"\"\n\nNS_ :\nBS_:\nBU_: XXX\n";
}
DbcEditorSource resolve_dbc_editor_source(const std::string &dbc_name) {
const fs::path generated_dbc_dir = repo_root() / "tools" / "jotpluggler" / "generated_dbcs";
const std::array<DbcEditorSource, 2> candidates = {{
{.path = repo_root() / "opendbc" / "dbc" / (dbc_name + ".dbc"), .kind = DbcEditorState::SourceKind::Opendbc},
{.path = generated_dbc_dir / (dbc_name + ".dbc"), .kind = DbcEditorState::SourceKind::Generated},
}};
for (const DbcEditorSource &candidate : candidates) {
if (fs::exists(candidate.path)) {
return candidate;
}
}
return {};
}
void load_dbc_editor_state(const AppSession &session, UiState *state) {
DbcEditorState &editor = state->dbc_editor;
const std::string dbc_name = !session.dbc_override.empty() ? session.dbc_override : session.route_data.dbc_name;
editor.source_name = dbc_name.empty() ? "untitled" : dbc_name;
editor.source_path.clear();
editor.source_kind = DbcEditorState::SourceKind::None;
if (dbc_name.empty()) {
editor.save_name = "custom_can";
editor.text = default_dbc_template();
} else {
const DbcEditorSource source = resolve_dbc_editor_source(dbc_name);
editor.source_kind = source.kind;
editor.source_path = source.path;
editor.text = source.path.empty() ? default_dbc_template() : read_file_or_throw(source.path);
editor.save_name = source.kind == DbcEditorState::SourceKind::Generated ? dbc_name : dbc_name + "_edited";
}
editor.loaded = true;
}
bool ensure_dbc_editor_loaded(const AppSession &session, UiState *state) {
if (!state->dbc_editor.loaded) {
try {
load_dbc_editor_state(session, state);
} catch (const std::exception &err) {
state->error_text = err.what();
state->open_error_popup = true;
return false;
}
}
return true;
}
bool save_dbc_editor_contents(AppSession *session, UiState *state) {
DbcEditorState &editor = state->dbc_editor;
editor.save_name = util::strip(editor.save_name);
if (editor.save_name.empty()) {
state->error_text = "DBC name cannot be empty";
state->open_error_popup = true;
return false;
}
if (editor.source_kind == DbcEditorState::SourceKind::Opendbc && editor.save_name == editor.source_name) {
state->error_text = "Save edited opendbc files under a new name";
state->open_error_popup = true;
return false;
}
try {
dbc::Database::fromContent(editor.text, editor.save_name + ".dbc");
const fs::path generated_dbc_dir = repo_root() / "tools" / "jotpluggler" / "generated_dbcs";
fs::create_directories(generated_dbc_dir);
const fs::path output = generated_dbc_dir / (editor.save_name + ".dbc");
write_file_or_throw(output, editor.text);
apply_dbc_override_change(session, state, editor.save_name);
editor.source_name = editor.save_name;
editor.source_path = output;
editor.source_kind = DbcEditorState::SourceKind::Generated;
editor.loaded = false;
state->status_text = "Saved DBC " + editor.save_name;
return true;
} catch (const std::exception &err) {
state->error_text = err.what();
state->open_error_popup = true;
return false;
}
}
void draw_dbc_editor_popup(AppSession *session, UiState *state) {
if (!ImGui::BeginPopupModal("DBC Editor", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
return;
}
DbcEditorState &editor = state->dbc_editor;
if (!ensure_dbc_editor_loaded(*session, state)) {
ImGui::CloseCurrentPopup();
ImGui::EndPopup();
return;
}
ImGui::TextUnformatted("Edit DBC text and save it into generated_dbcs.");
ImGui::Separator();
ImGui::SetNextItemWidth(260.0f);
input_text_string("DBC Name", &editor.save_name, ImGuiInputTextFlags_AutoSelectAll);
if (!editor.source_path.empty()) {
ImGui::TextDisabled("%s", editor.source_path.string().c_str());
} else {
ImGui::TextDisabled("New in-memory DBC");
}
ImGui::Spacing();
input_text_multiline_string("##dbc_editor_text", &editor.text, ImVec2(920.0f, 520.0f), ImGuiInputTextFlags_AllowTabInput);
ImGui::Spacing();
if (ImGui::Button("Apply + Save", ImVec2(140.0f, 0.0f))) {
if (save_dbc_editor_contents(session, state)) {
ImGui::CloseCurrentPopup();
}
}
ImGui::SameLine();
if (ImGui::Button("Reload Source", ImVec2(140.0f, 0.0f))) {
editor.loaded = false;
}
ImGui::SameLine();
if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) {
editor.loaded = false;
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
void draw_axis_limits_popup(AppSession *session, UiState *state) {
if (!ImGui::BeginPopupModal("Edit Axis Limits", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
return;
}
const WorkspaceTab *tab = app_active_tab(session->layout, *state);
const bool valid_pane = tab != nullptr
&& state->axis_limits.pane_index >= 0
&& state->axis_limits.pane_index < static_cast<int>(tab->panes.size());
if (!valid_pane) {
ImGui::TextWrapped("The selected pane is no longer available.");
ImGui::Spacing();
if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) {
state->axis_limits.pane_index = -1;
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
return;
}
ImGui::TextUnformatted("X range applies to the active tab. Y limits apply to the selected pane.");
ImGui::Separator();
ImGui::TextUnformatted("Horizontal");
ImGui::SetNextItemWidth(180.0f);
ImGui::InputDouble("X Min", &state->axis_limits.x_min, 0.0, 0.0, "%.3f");
ImGui::SetNextItemWidth(180.0f);
ImGui::InputDouble("X Max", &state->axis_limits.x_max, 0.0, 0.0, "%.3f");
ImGui::Spacing();
ImGui::TextUnformatted("Vertical");
ImGui::Checkbox("Use Y Min", &state->axis_limits.y_min_enabled);
ImGui::BeginDisabled(!state->axis_limits.y_min_enabled);
ImGui::SetNextItemWidth(180.0f);
ImGui::InputDouble("Y Min", &state->axis_limits.y_min, 0.0, 0.0, "%.6g");
ImGui::EndDisabled();
ImGui::Checkbox("Use Y Max", &state->axis_limits.y_max_enabled);
ImGui::BeginDisabled(!state->axis_limits.y_max_enabled);
ImGui::SetNextItemWidth(180.0f);
ImGui::InputDouble("Y Max", &state->axis_limits.y_max, 0.0, 0.0, "%.6g");
ImGui::EndDisabled();
ImGui::Spacing();
switch (draw_modal_action_row("Apply")) {
case ModalAction::Primary:
if (apply_axis_limits_editor(session, state)) {
state->axis_limits.pane_index = -1;
ImGui::CloseCurrentPopup();
}
break;
case ModalAction::Secondary:
state->axis_limits.pane_index = -1;
ImGui::CloseCurrentPopup();
break;
case ModalAction::None:
break;
}
ImGui::EndPopup();
}
void draw_error_popup(UiState *state) {
if (state->open_error_popup) {
ImGui::OpenPopup("Error");
state->open_error_popup = false;
}
if (!ImGui::BeginPopupModal("Error", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
return;
}
ImGui::TextWrapped("%s", state->error_text.c_str());
ImGui::Spacing();
if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
} // namespace
bool reset_layout(AppSession *session, UiState *state) {
try {
if (session->layout_path.empty()) {
start_new_layout(session, state, "Reset layout");
return true;
}
clear_layout_autosave(*session);
session->layout = load_sketch_layout(session->layout_path);
state->layout_dirty = false;
session->autosave_path = autosave_path_for_layout(session->layout_path);
state->undo.reset(session->layout);
refresh_replaced_layout_ui(session, state, false);
reset_shared_range(state, *session);
state->status_text = "Reset layout";
return true;
} catch (const std::exception &err) {
state->error_text = err.what();
state->open_error_popup = true;
state->status_text = "Failed to reset layout";
return false;
}
}
bool reload_layout(AppSession *session, UiState *state, const std::string &layout_arg) {
try {
const bool preserve_shared_range = session->route_data.has_time_range && state->has_shared_range;
const double preserved_x_min = state->x_view_min;
const double preserved_x_max = state->x_view_max;
const fs::path layout_path = resolve_layout_path(layout_arg);
session->autosave_path = autosave_path_for_layout(layout_path);
const bool load_draft = fs::exists(session->autosave_path);
session->layout = load_sketch_layout(load_draft ? session->autosave_path : layout_path);
session->layout_path = layout_path;
state->layout_dirty = load_draft;
state->undo.reset(session->layout);
refresh_replaced_layout_ui(session, state, true);
if (preserve_shared_range) {
state->has_shared_range = true;
state->x_view_min = preserved_x_min;
state->x_view_max = preserved_x_max;
clamp_shared_range(state, *session);
} else {
reset_shared_range(state, *session);
}
state->status_text = std::string(load_draft ? "Loaded layout draft " : "Loaded layout ")
+ layout_path.filename().string();
return true;
} catch (const std::exception &err) {
state->error_text = err.what();
state->open_error_popup = true;
state->status_text = "Failed to load layout";
return false;
}
}
bool save_layout(AppSession *session, UiState *state, const std::string &layout_path) {
try {
if (layout_path.empty()) throw std::runtime_error("Layout path is empty");
session->layout.current_tab_index = state->active_tab_index;
const fs::path previous_autosave = session->autosave_path;
const fs::path output = fs::absolute(fs::path(layout_path));
save_layout_json(session->layout, output);
session->layout_path = output;
session->autosave_path = autosave_path_for_layout(output);
if (!previous_autosave.empty() && previous_autosave != session->autosave_path && fs::exists(previous_autosave)) {
fs::remove(previous_autosave);
}
clear_layout_autosave(*session);
state->layout_dirty = false;
sync_layout_buffers(state, *session);
state->status_text = "Saved layout " + output.filename().string();
return true;
} catch (const std::exception &err) {
state->error_text = err.what();
state->open_error_popup = true;
state->status_text = "Failed to save layout";
return false;
}
}
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));
}
void stop_stream_session(AppSession *session, UiState *state, bool preserve_data) {
if (preserve_data && session->stream_poller && session->data_mode == SessionDataMode::Stream) {
session->stream_poller->setPaused(true);
} else if (session->stream_poller) {
session->stream_poller->stop();
}
session->stream_paused = preserve_data && session->data_mode == SessionDataMode::Stream;
if (!preserve_data) {
session->stream_time_offset.reset();
apply_route_data(session, state, RouteData{});
}
sync_stream_buffers(state, *session);
}
bool start_stream_session(AppSession *session,
UiState *state,
const StreamSourceConfig &source,
double buffer_seconds,
bool preserve_existing_data) {
try {
if (session->route_loader) {
session->route_loader.reset();
}
session->data_mode = SessionDataMode::Stream;
session->route_id = {};
session->route_name.clear();
session->data_dir.clear();
session->stream_source = source;
if (session->stream_source.kind == StreamSourceKind::CerealLocal) {
session->stream_source.address = "127.0.0.1";
}
session->stream_buffer_seconds = std::max(1.0, buffer_seconds);
session->next_stream_custom_refresh_time = 0.0;
session->stream_paused = false;
if (preserve_existing_data && session->stream_poller) {
StreamPollSnapshot snapshot = session->stream_poller->snapshot();
if (snapshot.active) {
session->stream_poller->setPaused(false);
sync_route_buffers(state, *session);
sync_stream_buffers(state, *session);
state->follow_latest = true;
state->playback_playing = false;
state->status_text = "Resumed stream " + stream_source_target_label(session->stream_source);
return true;
}
}
if (!preserve_existing_data) {
session->stream_time_offset.reset();
apply_route_data(session, state, RouteData{});
}
if (!session->stream_poller) {
session->stream_poller = std::make_unique<StreamPoller>();
}
session->stream_poller->start(session->stream_source,
session->stream_buffer_seconds,
session->dbc_override,
session->stream_time_offset);
sync_route_buffers(state, *session);
sync_stream_buffers(state, *session);
state->follow_latest = true;
state->playback_playing = false;
state->status_text = preserve_existing_data ? "Resumed stream " + stream_source_target_label(session->stream_source)
: "Streaming from " + stream_source_target_label(session->stream_source);
return true;
} catch (const std::exception &err) {
state->error_text = err.what();
state->open_error_popup = true;
state->status_text = "Failed to start stream";
return false;
}
}
void start_async_route_load(AppSession *session, UiState *state) {
if (!session->route_loader) {
return;
}
apply_route_data(session, state, RouteData{});
session->route_loader->start(session->route_name, session->data_dir, session->dbc_override);
state->status_text = session->route_name.empty() ? "Ready" : "Loading route " + session->route_name;
}
void poll_async_route_load(AppSession *session, UiState *state) {
if (!session->route_loader) {
return;
}
RouteData loaded_route;
std::string error_text;
if (!session->route_loader->consume(&loaded_route, &error_text)) {
return;
}
if (!error_text.empty()) {
state->error_text = error_text;
state->open_error_popup = true;
state->status_text = "Failed to load route";
return;
}
apply_route_data(session, state, std::move(loaded_route));
state->status_text = session->route_name.empty() ? "Ready" : "Loaded route " + session->route_name;
}
bool reload_session(AppSession *session, UiState *state, const std::string &route_name, const std::string &data_dir) {
try {
stop_stream_session(session, state, false);
session->data_mode = SessionDataMode::Route;
session->route_name = route_name;
session->route_id = parse_route_identifier(route_name);
session->data_dir = data_dir;
if (session->async_route_loading) {
if (!session->route_loader) {
session->route_loader = std::make_unique<AsyncRouteLoader>(::isatty(STDERR_FILENO) != 0);
}
start_async_route_load(session, state);
} else {
rebuild_session_route_data(session, state);
state->status_text = "Loaded route " + route_name;
}
sync_route_buffers(state, *session);
return true;
} catch (const std::exception &err) {
state->error_text = err.what();
state->open_error_popup = true;
state->status_text = "Failed to load route";
return false;
}
}
void draw_popups(AppSession *session, UiState *state) {
open_queued_popup(state->open_open_route, "Open Route");
if (state->open_stream) {
sync_stream_buffers(state, *session);
}
open_queued_popup(state->open_stream, "Live Stream");
if (state->open_load_layout || state->open_save_layout) {
sync_layout_buffers(state, *session);
}
open_queued_popup(state->open_load_layout, "Load Layout");
open_queued_popup(state->open_save_layout, "Save Layout");
open_queued_popup(state->open_preferences, "Preferences");
open_queued_popup(state->dbc_editor.open, "DBC Editor");
open_queued_popup(state->open_find_signal, "Find Signal");
open_queued_popup(state->axis_limits.open, "Edit Axis Limits");
draw_open_route_popup(session, state);
draw_stream_popup(session, state);
draw_load_layout_popup(session, state);
draw_save_layout_popup(session, state);
draw_preferences_popup(session, state);
draw_dbc_editor_popup(session, state);
draw_find_signal_popup(session, state);
draw_axis_limits_popup(session, state);
draw_error_popup(state);
}
+128
View File
@@ -0,0 +1,128 @@
#include "tools/jotpluggler/app.h"
#include "tools/jotpluggler/common.h"
#include <cmath>
#include <iomanip>
#include <sstream>
#include <stdexcept>
#include "third_party/json11/json11.hpp"
namespace fs = std::filesystem;
namespace {
std::string curve_color_hex(const std::array<uint8_t, 3> &color) {
std::ostringstream hex;
hex << "#" << std::hex << std::setfill('0')
<< std::setw(2) << static_cast<int>(color[0])
<< std::setw(2) << static_cast<int>(color[1])
<< std::setw(2) << static_cast<int>(color[2]);
return hex.str();
}
json11::Json curve_to_json(const Curve &curve) {
json11::Json::object obj = {
{"name", curve.name},
{"color", curve_color_hex(curve.color)},
};
if (curve.derivative) {
obj["transform"] = "derivative";
if (curve.derivative_dt > 0.0) {
obj["derivative_dt"] = curve.derivative_dt;
}
} else if (std::abs(curve.value_scale - 1.0) > 1.0e-9 || std::abs(curve.value_offset) > 1.0e-9) {
obj["transform"] = "scale";
obj["scale"] = curve.value_scale;
obj["offset"] = curve.value_offset;
}
if (curve.custom_python.has_value()) {
json11::Json::array additional_sources;
for (const std::string &path : curve.custom_python->additional_sources) {
additional_sources.push_back(path);
}
obj["custom_python"] = json11::Json::object{
{"linked_source", curve.custom_python->linked_source},
{"additional_sources", additional_sources},
{"globals_code", curve.custom_python->globals_code},
{"function_code", curve.custom_python->function_code},
};
}
return obj;
}
json11::Json workspace_node_to_json(const WorkspaceNode &node, const WorkspaceTab &tab) {
if (node.is_pane) {
if (node.pane_index < 0 || node.pane_index >= static_cast<int>(tab.panes.size())) {
return nullptr;
}
const Pane &pane = tab.panes[static_cast<size_t>(node.pane_index)];
json11::Json::object obj = {
{"title", pane.title.empty() ? std::string("...") : pane.title},
};
if (pane.kind == PaneKind::Map) {
obj["kind"] = "map";
} else if (pane.kind == PaneKind::Camera) {
obj["kind"] = "camera";
obj["camera_view"] = camera_view_spec(pane.camera_view).layout_name;
}
if (pane.range.valid) {
obj["range"] = json11::Json::object{
{"left", pane.range.left}, {"right", pane.range.right},
{"top", pane.range.top}, {"bottom", pane.range.bottom},
};
}
if (pane.range.has_y_limit_min || pane.range.has_y_limit_max) {
json11::Json::object limits;
if (pane.range.has_y_limit_min) {
limits["min"] = pane.range.y_limit_min;
}
if (pane.range.has_y_limit_max) {
limits["max"] = pane.range.y_limit_max;
}
obj["y_limits"] = limits;
}
json11::Json::array curves;
for (const Curve &curve : pane.curves) {
if (!curve.runtime_only) {
curves.push_back(curve_to_json(curve));
}
}
obj["curves"] = curves;
return obj;
}
if (node.children.empty()) return nullptr;
json11::Json::array sizes;
for (size_t i = 0; i < node.children.size(); ++i) {
sizes.push_back(i < node.sizes.size() ? static_cast<double>(node.sizes[i])
: 1.0 / static_cast<double>(node.children.size()));
}
json11::Json::array children;
for (const WorkspaceNode &child : node.children) {
children.push_back(workspace_node_to_json(child, tab));
}
return json11::Json::object{
{"split", node.orientation == SplitOrientation::Horizontal ? "horizontal" : "vertical"},
{"sizes", sizes},
{"children", children},
};
}
} // namespace
void save_layout_json(const SketchLayout &layout, const fs::path &path) {
ensure_parent_dir(path);
json11::Json::array tabs;
for (const WorkspaceTab &tab : layout.tabs) {
tabs.push_back(json11::Json::object{
{"name", tab.tab_name},
{"root", workspace_node_to_json(tab.root, tab)},
});
}
const json11::Json root = json11::Json::object{
{"current_tab_index", std::clamp(layout.current_tab_index, 0, std::max(0, static_cast<int>(layout.tabs.size()) - 1))},
{"tabs", tabs},
};
write_file_or_throw(path, root.dump() + "\n");
}
+1
View File
@@ -0,0 +1 @@
.jotpluggler_autosave/
@@ -0,0 +1 @@
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.33362,0.33276,0.33362],"children":[{"title":"CAN RX","range":{"left":0.0,"right":60.526742,"top":1101.875,"bottom":-26.875},"curves":[{"name":"/pandaStates/0/canState0/totalRxCnt","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState1/totalRxCnt","color":"#9467bd","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState2/totalRxCnt","color":"#ff7f0e","transform":"derivative","derivative_dt":1.0}]},{"title":"CAN TX","range":{"left":0.0,"right":60.526742,"top":455.1,"bottom":-11.1},"curves":[{"name":"/pandaStates/0/canState0/totalTxCnt","color":"#17becf","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState1/totalTxCnt","color":"#bcbd22","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState2/totalTxCnt","color":"#1f77b4","transform":"derivative","derivative_dt":1.0}]},{"title":"CAN errors","range":{"left":0.0,"right":60.526742,"top":2515.35,"bottom":-61.35},"curves":[{"name":"/pandaStates/0/canState0/totalErrorCnt","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState1/totalErrorCnt","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState2/totalErrorCnt","color":"#1ac938","transform":"derivative","derivative_dt":1.0}]}]}}]}
@@ -0,0 +1 @@
{"current_tab_index":0,"tabs":[{"name":"SOF / EOF (encodeIdx)","root":{"split":"vertical","sizes":[0.500885,0.499115],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverEncodeIdx/timestampSof","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/roadEncodeIdx/timestampSof","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadEncodeIdx/timestampSof","color":"#1ac938","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}},{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverEncodeIdx/timestampEof","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/roadEncodeIdx/timestampEof","color":"#9467bd","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadEncodeIdx/timestampEof","color":"#17becf","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}}]}},{"name":"model timings","root":{"split":"vertical","sizes":[0.5,0.5],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.016865,"bottom":0.015143},"curves":[{"name":"/modelV2/modelExecutionTime","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.1,"bottom":-0.1},"curves":[{"name":"/modelV2/frameDropPerc","color":"#f14cc1"}]}]}},{"name":"sensor info","root":{"split":"vertical","sizes":[1.0],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.1,"bottom":-0.1},"curves":[{"name":"/driverCameraState/sensor","color":"#bcbd22"},{"name":"/roadCameraState/sensor","color":"#1f77b4"},{"name":"/wideRoadCameraState/sensor","color":"#d62728"}]}]}},{"name":"SOF / EOF (cameraState)","root":{"split":"vertical","sizes":[0.500885,0.499115],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverCameraState/timestampSof","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/roadCameraState/timestampSof","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadCameraState/timestampSof","color":"#1ac938","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}},{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverCameraState/timestampEof","color":"#ff7f0e","transform":"derivative","derivative_dt":1.0},{"name":"/roadCameraState/timestampEof","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadCameraState/timestampEof","color":"#9467bd","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}}]}}]}
@@ -0,0 +1 @@
{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"children": [{"children": [{"curves": [], "kind": "map", "title": "Map"}, {"camera_view": "road", "curves": [], "kind": "camera", "title": "Road Camera"}], "sizes": [0.5, 0.5], "split": "horizontal"}, {"children": [{"camera_view": "wide_road", "curves": [], "kind": "camera", "title": "Wide Road Camera"}, {"camera_view": "driver", "curves": [], "kind": "camera", "title": "Driver Camera"}], "sizes": [0.5, 0.5], "split": "horizontal"}], "sizes": [0.5, 0.5], "split": "vertical"}}]}
@@ -0,0 +1 @@
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.500381,0.499619],"children":[{"split":"horizontal","sizes":[0.5,0.5],"children":[{"title":"...","range":{"left":0.0,"right":632.799721,"top":771630.925,"bottom":-17755.925},"curves":[{"name":"/pandaStates/0/canState0/totalRxCnt","color":"#1f77b4"},{"name":"/pandaStates/0/canState1/totalRxCnt","color":"#d62728"},{"name":"/pandaStates/0/canState2/totalRxCnt","color":"#1ac938"}]},{"title":"...","range":{"left":0.0,"right":632.799721,"top":760365.5,"bottom":-18545.5},"curves":[{"name":"/pandaStates/0/canState0/totalTxCnt","color":"#ff7f0e"},{"name":"/pandaStates/0/canState1/totalTxCnt","color":"#f14cc1"},{"name":"/pandaStates/0/canState2/totalTxCnt","color":"#9467bd"}]}]},{"split":"horizontal","sizes":[0.333333,0.333333,0.333333],"children":[{"title":"...","range":{"left":0.0,"right":632.799721,"top":55.35,"bottom":-1.35},"curves":[{"name":"/pandaStates/0/canState0/totalRxLostCnt","color":"#ff7f0e"},{"name":"/pandaStates/0/canState1/totalRxLostCnt","color":"#f14cc1"},{"name":"/pandaStates/0/canState2/totalRxLostCnt","color":"#9467bd"}]},{"title":"...","range":{"left":0.0,"right":632.799721,"top":2.05,"bottom":-0.05},"curves":[{"name":"/pandaStates/0/canState0/totalTxLostCnt","color":"#17becf"},{"name":"/pandaStates/0/canState1/totalTxLostCnt","color":"#bcbd22"},{"name":"/pandaStates/0/canState2/totalTxLostCnt","color":"#1f77b4"}]},{"title":"...","range":{"left":0.0,"right":632.799721,"top":0.1,"bottom":-0.1},"curves":[{"name":"/pandaStates/0/canState0/busOffCnt","color":"#17becf"},{"name":"/pandaStates/0/canState1/busOffCnt","color":"#1ac938"},{"name":"/pandaStates/0/canState2/busOffCnt","color":"#bcbd22"}]}]}]}}]}
@@ -0,0 +1 @@
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.2,0.2,0.2,0.2,0.2],"children":[{"title":"...","range":{"left":0.018309,"right":59.674401,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carControl/enabled","color":"#1f77b4"},{"name":"/pandaStates/0/controlsAllowed","color":"#d62728"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":27.087398,"bottom":-0.905168},"curves":[{"name":"/carState/cumLagMs","color":"#9467bd"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":1.025,"bottom":-0.025},"curves":[{"name":"/pandaStates/0/safetyRxInvalid","color":"#1f77b4"},{"name":"/pandaStates/0/safetyRxChecksInvalid","color":"#e801ce"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":158.85,"bottom":-2.85},"curves":[{"name":"/pandaStates/0/safetyTxBlocked","color":"#d62728"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carState/gasPressed","color":"#1ac938"},{"name":"/carState/brakePressed","color":"#ff7f0e"}]}]}}]}
+1
View File
@@ -0,0 +1 @@
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.24977,0.250689,0.24977,0.24977],"children":[{"title":"...","range":{"left":0.0,"right":1678.753571,"top":1.025,"bottom":-0.025},"curves":[{"name":"/gpsLocationExternal/hasFix","color":"#1f77b4"}]},{"title":"...","range":{"left":0.0,"right":1678.753571,"top":17.425,"bottom":-0.425},"curves":[{"name":"/gpsLocationExternal/satelliteCount","color":"#d62728"}]},{"title":"...","range":{"left":0.0,"right":1678.753571,"top":3.0,"bottom":0.0},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#1ac938"}],"y_limits":{"min":0.0,"max":3.0}},{"title":"...","range":{"left":0.0,"right":1678.753571,"top":766.374004,"bottom":-17.262},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#1ac938"}]}]}}]}
@@ -0,0 +1 @@
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.333805,0.33239,0.333805],"children":[{"title":"...","range":{"left":76.646983,"right":196.811937,"top":32.070386,"bottom":0.368228},"curves":[{"name":"haversine distance [m]","color":"#1f77b4","custom_python":{"linked_source":"/gpsLocationExternal/latitude","additional_sources":["/gpsLocationExternal/longitude","/liveLocationKalmanDEPRECATED/positionGeodetic/value/0","/liveLocationKalmanDEPRECATED/positionGeodetic/value/1"],"globals_code":"R = 6378.137 # Radius of earth in KM","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n global R\n # Compute the Haversine distance between\n # two points defined by latitude and longitude.\n # Return the distance in meters\n lat1, lon1 = value, v1\n lat2, lon2 = v2, v3\n dLat = (lat2 - lat1) * np.pi / 180\n dLon = (lon2 - lon1) * np.pi / 180\n a = np.sin(dLat/2) * np.sin(dLat/2) +\n np.cos(lat1 * np.pi / 180) * np.cos(lat2 * np.pi / 180) *\n np.sin(dLon/2) * np.sin(dLon/2)\n c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1-a))\n d = R * c\n distance = d * 1000 # meters\n return distance\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":76.646983,"right":196.811937,"top":12.637299,"bottom":-0.259115},"curves":[{"name":"/carState/vEgo","color":"#17becf"},{"name":"/gpsLocationExternal/speed","color":"#bcbd22"}]},{"split":"horizontal","sizes":[0.500516,0.499484],"children":[{"title":"...","range":{"left":76.646983,"right":196.811937,"top":0.1,"bottom":-0.1},"curves":[{"name":"/liveLocationKalmanDEPRECATED/positionGeodetic/std/0","color":"#d62728"},{"name":"/liveLocationKalmanDEPRECATED/positionGeodetic/std/1","color":"#1ac938"}]},{"title":"...","range":{"left":76.646983,"right":196.811937,"top":7.160833,"bottom":-0.449385},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#ff7f0e"},{"name":"/gpsLocationExternal/verticalAccuracy","color":"#f14cc1"},{"name":"/gpsLocationExternal/speedAccuracy","color":"#9467bd"}]}]}]}}]}
@@ -0,0 +1 @@
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.166588,0.167062,0.166113,0.166588,0.167062,0.166588],"children":[{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1.025,"bottom":-0.025},"curves":[{"name":"/livePose/inputsOK","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":14.542814,"bottom":-5.586039},"curves":[{"name":"/accelerometer/acceleration/v/0","color":"#f14cc1"},{"name":"/accelerometer/acceleration/v/1","color":"#9467bd"},{"name":"/accelerometer/acceleration/v/2","color":"#17becf"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":0.988911,"bottom":-0.745939},"curves":[{"name":"/gyroscope/gyroUncalibrated/v/0","color":"#d62728"},{"name":"/gyroscope/gyroUncalibrated/v/1","color":"#1ac938"},{"name":"/gyroscope/gyroUncalibrated/v/2","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1.025,"bottom":-0.025},"curves":[{"name":"/accelerometer/__valid","color":"#17becf"},{"name":"/gyroscope/__valid","color":"#bcbd22"},{"name":"/carState/__valid","color":"#f14cc1"},{"name":"/liveCalibration/__valid","color":"#1ac938"},{"name":"/cameraOdometry/__valid","color":"#9467bd"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1000000000.292252,"bottom":999999999.735447},"curves":[{"name":"/gyroscope/__logMonoTime","color":"#1f77b4","transform":"derivative"},{"name":"/accelerometer/__logMonoTime","color":"#d62728","transform":"derivative"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":20790107743.93223,"bottom":-529653831.495853},"curves":[{"name":"/accelerometer/timestamp","color":"#bcbd22","transform":"derivative"},{"name":"/gyroscope/timestamp","color":"#1f77b4","transform":"derivative"}]}]}}]}
@@ -0,0 +1 @@
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.250401,0.249599,0.250401,0.249599],"children":[{"title":"...","range":{"left":104.907277,"right":126.285782,"top":1.391623,"bottom":-2.563614},"curves":[{"name":"/carState/aEgo","color":"#f14cc1"},{"name":"/longitudinalPlan/accels/0","color":"#9467bd"},{"name":"/carControl/actuators/accel","color":"#17becf"},{"name":"/carOutput/actuatorsOutput/accel","color":"#d62728"}]},{"title":"...","range":{"left":104.907277,"right":126.285782,"top":1.18496,"bottom":-1.811222},"curves":[{"name":"/controlsState/upAccelCmd","color":"#1f77b4"},{"name":"/controlsState/uiAccelCmd","color":"#d62728"},{"name":"/controlsState/ufAccelCmd","color":"#1ac938"}]},{"title":"...","range":{"left":104.907277,"right":126.285782,"top":15.862889,"bottom":-0.568809},"curves":[{"name":"/carState/vEgo","color":"#1ac938"},{"name":"/longitudinalPlan/speeds/0","color":"#ff7f0e"}]},{"title":"...","range":{"left":104.907277,"right":126.285782,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carControl/longActive","color":"#1f77b4"},{"name":"/carState/gasPressed","color":"#d62728"}]}]}}]}
@@ -0,0 +1 @@
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.249724,0.250829,0.249724,0.249724],"children":[{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":6.050533,"bottom":-7.599037},"curves":[{"name":"Actual lateral accel (roll compensated)","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"Desired lateral accel (roll compensated)","color":"#ff7f0e","custom_python":{"linked_source":"/controlsState/desiredCurvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":5.384416,"bottom":-7.503945},"curves":[{"name":"roll compensated lateral acceleration","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/liveParameters/roll","/carState/steeringPressed","/carControl/latActive"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3, v4):\n if (v3 == 0 and v4 == 1):\n return (value * v1 ** 2) - (v2 * 9.81)\n return 0\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i], v4[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":1.05,"bottom":-1.05},"curves":[{"name":"/carState/steeringPressed","color":"#0097ff"},{"name":"/carOutput/actuatorsOutput/torque","color":"#d62728"}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":80.762969,"bottom":-2.181837},"curves":[{"name":"/carState/vEgo","color":"#f14cc1","transform":"scale","scale":2.23694,"offset":0.0}]}]}}]}
@@ -0,0 +1 @@
{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"curves": [], "title": "..."}}]}
@@ -0,0 +1 @@
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.249729,0.250814,0.249729,0.249729],"children":[{"title":"...","range":{"left":0.0,"right":59.992103,"top":102.5,"bottom":-2.5},"curves":[{"name":"/deviceState/cpuUsagePercent/0","color":"#1f77b4"},{"name":"/deviceState/cpuUsagePercent/1","color":"#d62728"},{"name":"/deviceState/cpuUsagePercent/2","color":"#1ac938"},{"name":"/deviceState/cpuUsagePercent/3","color":"#ff7f0e"},{"name":"/deviceState/cpuUsagePercent/4","color":"#f14cc1"},{"name":"/deviceState/cpuUsagePercent/5","color":"#9467bd"},{"name":"/deviceState/cpuUsagePercent/6","color":"#17becf"},{"name":"/deviceState/cpuUsagePercent/7","color":"#bcbd22"}]},{"title":"...","range":{"left":0.0,"right":59.992103,"top":64.005001,"bottom":51.195},"curves":[{"name":"/deviceState/cpuTempC/0","color":"#d62728"},{"name":"/deviceState/cpuTempC/1","color":"#1ac938"},{"name":"/deviceState/cpuTempC/2","color":"#ff7f0e"},{"name":"/deviceState/cpuTempC/3","color":"#f14cc1"},{"name":"/deviceState/cpuTempC/4","color":"#9467bd"},{"name":"/deviceState/cpuTempC/5","color":"#17becf"},{"name":"/deviceState/cpuTempC/6","color":"#bcbd22"},{"name":"/deviceState/cpuTempC/7","color":"#1f77b4"},{"name":"/deviceState/gpuTempC/0","color":"#d62728"},{"name":"/deviceState/gpuTempC/1","color":"#1ac938"}]},{"title":"...","range":{"left":0.0,"right":59.992103,"top":37.371108,"bottom":-0.91149},"curves":[{"name":"/modelV2/frameDropPerc","color":"#f14cc1"}]},{"title":"...","range":{"left":0.0,"right":59.992103,"top":-3.593455,"bottom":-12.190956},"curves":[{"name":"/carState/cumLagMs","color":"#9467bd"}]}]}}]}
@@ -0,0 +1 @@
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.166785,0.166785,0.166075,0.166785,0.166785,0.166785],"children":[{"title":"...","range":{"left":0.006955,"right":301.842654,"top":87.987497,"bottom":75.912497},"curves":[{"name":"/deviceState/cpuTempC/0","color":"#1f77b4"},{"name":"/deviceState/cpuTempC/1","color":"#d62728"},{"name":"/deviceState/cpuTempC/2","color":"#1ac938"},{"name":"/deviceState/cpuTempC/3","color":"#ff7f0e"},{"name":"/deviceState/cpuTempC/4","color":"#f14cc1"},{"name":"/deviceState/cpuTempC/5","color":"#9467bd"},{"name":"/deviceState/cpuTempC/6","color":"#17becf"},{"name":"/deviceState/cpuTempC/7","color":"#bcbd22"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":85.861052,"bottom":66.49695},"curves":[{"name":"/deviceState/pmicTempC/0","color":"#1f77b4"},{"name":"/deviceState/gpuTempC/0","color":"#d62728"},{"name":"/deviceState/gpuTempC/1","color":"#1ac938"},{"name":"/deviceState/memoryTempC","color":"#f14cc1"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":86.207876,"bottom":70.665918},"curves":[{"name":"/deviceState/maxTempC","color":"#1f77b4"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":1.025,"bottom":-0.025},"curves":[{"name":"/deviceState/thermalStatus","color":"#1f77b4"}]},{"split":"horizontal","sizes":[0.333124,0.333752,0.333124],"children":[{"title":"...","range":{"left":0.006955,"right":301.842654,"top":12.057358,"bottom":4.843517},"curves":[{"name":"/deviceState/powerDrawW","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":100.0,"bottom":0.0},"curves":[{"name":"/deviceState/fanSpeedPercentDesired","color":"#9467bd"},{"name":"/pandaStates/0/fanPower","color":"#1f77b4"}],"y_limits":{"min":0.0,"max":100.0}},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":5018.4,"bottom":255.6},"curves":[{"name":"/peripheralState/fanSpeedRpm","color":"#1f77b4"}]}]},{"split":"horizontal","sizes":[0.502513,0.497487],"children":[{"title":"...","range":{"left":0.006955,"right":301.842654,"top":100.025,"bottom":14.975},"curves":[{"name":"/deviceState/cpuUsagePercent/0","color":"#1f77b4"},{"name":"/deviceState/cpuUsagePercent/1","color":"#d62728"},{"name":"/deviceState/cpuUsagePercent/2","color":"#1ac938"},{"name":"/deviceState/cpuUsagePercent/3","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":102.5,"bottom":-2.5},"curves":[{"name":"/deviceState/cpuUsagePercent/4","color":"#f14cc1"},{"name":"/deviceState/cpuUsagePercent/5","color":"#9467bd"},{"name":"/deviceState/cpuUsagePercent/6","color":"#17becf"},{"name":"/deviceState/cpuUsagePercent/7","color":"#bcbd22"}]}]}]}}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.333333,0.333333,0.333333],"children":[{"title":"...","range":{"left":0.0,"right":134.825489,"top":4402341.574525,"bottom":-107369.555525},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#1f77b4"}]},{"title":"...","range":{"left":0.0,"right":134.825489,"top":1.025,"bottom":-0.025},"curves":[{"name":"/gpsLocationExternal/flags","color":"#d62728"}]},{"title":"...","range":{"left":0.0,"right":134.825489,"top":6.15,"bottom":-0.15},"curves":[{"name":"/ubloxGnss/measurementReport/numMeas","color":"#1ac938"}]}]}}]}
+419
View File
@@ -0,0 +1,419 @@
#include "tools/jotpluggler/app.h"
#include <cmath>
#include <ctime>
namespace {
struct LevelOption {
const char *label;
int value;
};
constexpr std::array<LevelOption, 5> LEVEL_OPTIONS = {{
{"DEBUG", 10},
{"INFO", 20},
{"WARNING", 30},
{"ERROR", 40},
{"CRITICAL", 50},
}};
constexpr uint32_t ALL_LEVEL_MASK = (1u << LEVEL_OPTIONS.size()) - 1u;
bool log_matches_search(const LogEntry &entry, std::string_view query) {
if (query.empty()) return true;
const std::string needle = lowercase_copy(query);
const auto contains = [&](std::string_view haystack) {
return lowercase_copy(haystack).find(needle) != std::string::npos;
};
return contains(entry.message) || contains(entry.source) || contains(entry.func);
}
std::vector<std::string> collect_log_sources(const std::vector<LogEntry> &logs) {
std::vector<std::string> sources;
for (const LogEntry &entry : logs) {
if (entry.source.empty()) continue;
if (std::find(sources.begin(), sources.end(), entry.source) == sources.end()) {
sources.push_back(entry.source);
}
}
std::sort(sources.begin(), sources.end());
return sources;
}
std::vector<int> filter_log_indices(const RouteData &route_data, const LogsUiState &logs_state) {
std::vector<int> indices;
indices.reserve(route_data.logs.size());
for (size_t i = 0; i < route_data.logs.size(); ++i) {
const LogEntry &entry = route_data.logs[i];
int level_index = 0;
if (entry.level >= 50) {
level_index = 4;
} else if (entry.level >= 40) {
level_index = 3;
} else if (entry.level >= 30) {
level_index = 2;
} else if (entry.level >= 20) {
level_index = 1;
}
if ((logs_state.enabled_levels_mask & (1u << level_index)) == 0) {
continue;
}
if (!logs_state.all_sources) {
const auto it = std::find(logs_state.selected_sources.begin(),
logs_state.selected_sources.end(),
entry.source);
if (it == logs_state.selected_sources.end()) continue;
}
if (!log_matches_search(entry, logs_state.search)) continue;
indices.push_back(static_cast<int>(i));
}
return indices;
}
int find_active_log_position(const RouteData &route_data,
const std::vector<int> &filtered_indices,
double tracker_time) {
if (filtered_indices.empty()) return -1;
auto it = std::lower_bound(filtered_indices.begin(), filtered_indices.end(), tracker_time,
[&](int log_index, double tm) {
return route_data.logs[static_cast<size_t>(log_index)].mono_time < tm;
});
if (it == filtered_indices.begin()) return static_cast<int>(std::distance(filtered_indices.begin(), it));
if (it == filtered_indices.end()) return static_cast<int>(filtered_indices.size()) - 1;
if (route_data.logs[static_cast<size_t>(*it)].mono_time > tracker_time) {
--it;
}
return static_cast<int>(std::distance(filtered_indices.begin(), it));
}
std::string format_route_time(double seconds) {
if (seconds < 0.0) {
seconds = 0.0;
}
const int minutes = static_cast<int>(seconds / 60.0);
const double remaining = seconds - static_cast<double>(minutes) * 60.0;
return util::string_format("%d:%06.3f", minutes, remaining);
}
std::string format_boot_time(double seconds) {
return util::string_format("%.3f", seconds);
}
std::string format_wall_time(double seconds) {
if (seconds <= 0.0) return "--";
const time_t wall_seconds = static_cast<time_t>(seconds);
std::tm wall_tm = {};
localtime_r(&wall_seconds, &wall_tm);
const int millis = static_cast<int>(std::llround((seconds - std::floor(seconds)) * 1000.0));
return util::string_format("%02d:%02d:%02d.%03d",
wall_tm.tm_hour, wall_tm.tm_min, wall_tm.tm_sec, millis);
}
std::string format_log_time(const LogEntry &entry, LogTimeMode mode) {
switch (mode) {
case LogTimeMode::Route:
return format_route_time(entry.mono_time);
case LogTimeMode::Boot:
return format_boot_time(entry.boot_time);
case LogTimeMode::WallClock:
return format_wall_time(entry.wall_time);
}
return format_route_time(entry.mono_time);
}
const char *time_mode_label(LogTimeMode mode) {
switch (mode) {
case LogTimeMode::Route: return "Route";
case LogTimeMode::Boot: return "Boot";
case LogTimeMode::WallClock: return "Wall clock";
}
return "Route";
}
std::string level_filter_label(uint32_t mask) {
if (mask == ALL_LEVEL_MASK) return "All levels";
if (mask == 0b11110) return "INFO+";
if (mask == 0b11100) return "WARNING+";
if (mask == 0b11000) return "ERROR+";
if (mask == 0b10000) return "CRITICAL";
int enabled_count = 0;
const char *last_label = "None";
for (size_t i = 0; i < LEVEL_OPTIONS.size(); ++i) {
if ((mask & (1u << i)) == 0) {
continue;
}
++enabled_count;
last_label = LEVEL_OPTIONS[i].label;
}
if (enabled_count == 0) return "None";
if (enabled_count == 1) return last_label;
return "Custom";
}
std::string source_filter_label(const LogsUiState &logs_state, const std::vector<std::string> &sources) {
if (logs_state.all_sources || logs_state.selected_sources.size() == sources.size()) {
return "All sources";
}
if (logs_state.selected_sources.empty()) return "No sources";
if (logs_state.selected_sources.size() == 1) return logs_state.selected_sources.front();
return std::to_string(logs_state.selected_sources.size()) + " sources";
}
const char *level_label(const LogEntry &entry) {
if (entry.origin == LogOrigin::Alert) return "ALRT";
if (entry.level >= 50) return "CRIT";
if (entry.level >= 40) return "ERR";
if (entry.level >= 30) return "WARN";
if (entry.level >= 20) return "INFO";
return "DBG";
}
ImVec4 level_text_color(const LogEntry &entry, bool active) {
if (active) return color_rgb(46, 54, 63);
if (entry.origin == LogOrigin::Alert) return color_rgb(50, 100, 200);
if (entry.level >= 50) return color_rgb(176, 26, 18);
if (entry.level >= 40) return color_rgb(200, 50, 40);
if (entry.level >= 30) return color_rgb(200, 130, 0);
if (entry.level >= 20) return color_rgb(80, 86, 94);
return color_rgb(126, 133, 141);
}
ImU32 row_bg_color(const LogEntry &entry, bool active) {
if (active) return IM_COL32(80, 140, 210, 38);
return 0;
}
void set_tracker_to_log(UiState *state, const LogEntry &entry) {
state->tracker_time = entry.mono_time;
state->has_tracker_time = true;
state->logs.last_auto_scroll_time = entry.mono_time;
}
void draw_log_expansion_row(const LogEntry &entry) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::TextUnformatted("");
ImGui::TableSetColumnIndex(1);
ImGui::TextUnformatted("");
ImGui::TableSetColumnIndex(2);
ImGui::TextUnformatted(entry.func.empty() ? "" : entry.func.c_str());
ImGui::TableSetColumnIndex(3);
ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(96, 104, 113));
ImGui::TextWrapped("%s", entry.message.c_str());
if (!entry.func.empty()) {
ImGui::TextWrapped("func: %s", entry.func.c_str());
}
if (!entry.context.empty()) {
ImGui::TextWrapped("ctx: %s", entry.context.c_str());
}
ImGui::PopStyleColor();
}
void draw_log_row(const LogEntry &entry,
int log_index,
bool active,
UiState *state) {
ImGui::PushID(log_index);
const ImU32 bg = row_bg_color(entry, active);
ImGui::TableNextRow();
if (bg != 0) {
ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, bg);
}
const std::string time_text = std::string(active ? "\xE2\x96\xB6 " : " ") + format_log_time(entry, state->logs.time_mode);
const auto clickable_text = [&](const char *id, const std::string &text, ImVec4 color = color_rgb(74, 80, 88)) {
ImGui::PushID(id);
ImGui::PushStyleColor(ImGuiCol_Text, color);
ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(0, 0, 0, 0));
const bool clicked = ImGui::Selectable(text.c_str(), false, ImGuiSelectableFlags_AllowDoubleClick);
ImGui::PopStyleColor(4);
ImGui::PopID();
return clicked;
};
bool clicked = false;
ImGui::TableSetColumnIndex(0);
app_push_mono_font();
clicked = clickable_text("time", time_text);
app_pop_mono_font();
ImGui::TableSetColumnIndex(1);
clicked = clickable_text("level", level_label(entry), level_text_color(entry, active)) || clicked;
ImGui::TableSetColumnIndex(2);
clicked = clickable_text("source", entry.source) || clicked;
ImGui::TableSetColumnIndex(3);
clicked = clickable_text("message", entry.message) || clicked;
if (clicked) {
set_tracker_to_log(state, entry);
state->logs.expanded_index = state->logs.expanded_index == log_index ? -1 : log_index;
}
ImGui::PopID();
}
} // namespace
void draw_logs_tab(AppSession *session, UiState *state) {
LogsUiState &logs_state = state->logs;
const RouteData &route_data = session->route_data;
const RouteLoadSnapshot load = session->route_loader ? session->route_loader->snapshot() : RouteLoadSnapshot{};
const bool loading_logs = load.active && route_data.logs.empty();
const std::vector<std::string> sources = collect_log_sources(route_data.logs);
if (!logs_state.all_sources) {
logs_state.selected_sources.erase(
std::remove_if(logs_state.selected_sources.begin(),
logs_state.selected_sources.end(),
[&](const std::string &source) {
return std::find(sources.begin(), sources.end(), source) == sources.end();
}),
logs_state.selected_sources.end());
}
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(6.0f, 3.0f));
ImGui::SetNextItemWidth(110.0f);
const std::string levels_label = level_filter_label(logs_state.enabled_levels_mask);
if (ImGui::BeginCombo("##logs_level", levels_label.c_str())) {
bool all_levels = logs_state.enabled_levels_mask == ALL_LEVEL_MASK;
if (ImGui::Checkbox("All levels", &all_levels)) {
logs_state.enabled_levels_mask = all_levels ? ALL_LEVEL_MASK : 0u;
}
ImGui::Separator();
for (size_t i = 0; i < LEVEL_OPTIONS.size(); ++i) {
bool enabled = (logs_state.enabled_levels_mask & (1u << i)) != 0;
if (ImGui::Checkbox(LEVEL_OPTIONS[i].label, &enabled)) {
if (enabled) {
logs_state.enabled_levels_mask |= (1u << i);
} else {
logs_state.enabled_levels_mask &= ~(1u << i);
}
}
}
ImGui::EndCombo();
}
ImGui::SameLine();
ImGui::SetNextItemWidth(150.0f);
input_text_with_hint_string("##logs_search", "Search...", &logs_state.search);
ImGui::SameLine();
const std::string sources_label = source_filter_label(logs_state, sources);
ImGui::SetNextItemWidth(180.0f);
if (ImGui::BeginCombo("##logs_source", sources_label.c_str())) {
bool all_sources = logs_state.all_sources;
if (ImGui::Checkbox("All sources", &all_sources)) {
logs_state.all_sources = all_sources;
if (logs_state.all_sources) {
logs_state.selected_sources.clear();
} else {
logs_state.selected_sources = sources;
}
}
ImGui::Separator();
for (const std::string &source : sources) {
bool enabled = logs_state.all_sources
|| std::find(logs_state.selected_sources.begin(), logs_state.selected_sources.end(), source) != logs_state.selected_sources.end();
if (ImGui::Checkbox(source.c_str(), &enabled)) {
if (logs_state.all_sources) {
logs_state.all_sources = false;
logs_state.selected_sources = sources;
}
auto it = std::find(logs_state.selected_sources.begin(), logs_state.selected_sources.end(), source);
if (enabled) {
if (it == logs_state.selected_sources.end()) {
logs_state.selected_sources.push_back(source);
}
} else if (it != logs_state.selected_sources.end()) {
logs_state.selected_sources.erase(it);
}
if (logs_state.selected_sources.size() == sources.size()) {
logs_state.all_sources = true;
logs_state.selected_sources.clear();
}
}
}
ImGui::EndCombo();
}
ImGui::SameLine();
ImGui::SetNextItemWidth(110.0f);
if (ImGui::BeginCombo("##logs_time_mode", time_mode_label(logs_state.time_mode))) {
for (LogTimeMode mode : {LogTimeMode::Route, LogTimeMode::Boot, LogTimeMode::WallClock}) {
const bool selected = logs_state.time_mode == mode;
if (ImGui::Selectable(time_mode_label(mode), selected)) {
logs_state.time_mode = mode;
}
}
ImGui::EndCombo();
}
const std::vector<int> filtered_indices = filter_log_indices(route_data, logs_state);
const bool have_tracker = state->has_tracker_time && !filtered_indices.empty();
const int active_pos = have_tracker ? find_active_log_position(route_data, filtered_indices, state->tracker_time) : -1;
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowContentRegionMax().x - 110.0f));
ImGui::Text("%zu / %zu", filtered_indices.size(), route_data.logs.size());
ImGui::PopStyleVar();
if (route_data.logs.empty()) {
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(116, 124, 133));
ImGui::TextWrapped("%s", loading_logs ? "Loading logs..." : "No text logs available for this route.");
ImGui::PopStyleColor();
return;
}
if (ImGui::BeginChild("##logs_table_child", ImVec2(0.0f, 0.0f), false)) {
if (have_tracker && std::abs(logs_state.last_auto_scroll_time - state->tracker_time) > 1.0e-6) {
const float row_height = ImGui::GetTextLineHeightWithSpacing() + 6.0f;
const float visible_h = std::max(1.0f, ImGui::GetWindowHeight());
const float target = std::max(0.0f, static_cast<float>(active_pos) * row_height - visible_h * 0.45f);
ImGui::SetScrollY(target);
logs_state.last_auto_scroll_time = state->tracker_time;
}
if (ImGui::BeginTable("##logs_table",
4,
ImGuiTableFlags_BordersInnerV |
ImGuiTableFlags_RowBg |
ImGuiTableFlags_Resizable |
ImGuiTableFlags_SizingStretchProp)) {
ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 120.0f);
ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 72.0f);
ImGui::TableSetupColumn("Source", ImGuiTableColumnFlags_WidthFixed, 180.0f);
ImGui::TableSetupColumn("Message", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableHeadersRow();
const bool use_clipper = logs_state.expanded_index < 0;
if (use_clipper) {
ImGuiListClipper clipper;
clipper.Begin(static_cast<int>(filtered_indices.size()));
while (clipper.Step()) {
for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) {
const int log_index = filtered_indices[static_cast<size_t>(i)];
const LogEntry &entry = route_data.logs[static_cast<size_t>(log_index)];
draw_log_row(entry, log_index, i == active_pos, state);
}
}
} else {
for (int i = 0; i < static_cast<int>(filtered_indices.size()); ++i) {
const int log_index = filtered_indices[static_cast<size_t>(i)];
const LogEntry &entry = route_data.logs[static_cast<size_t>(log_index)];
draw_log_row(entry, log_index, i == active_pos, state);
if (logs_state.expanded_index == log_index) {
draw_log_expansion_row(entry);
}
}
}
ImGui::EndTable();
}
}
ImGui::EndChild();
}
+126
View File
@@ -0,0 +1,126 @@
#include <cstdlib>
#include <iostream>
#include "tools/jotpluggler/app.h"
namespace {
constexpr const char *DEMO_ROUTE = "5beb9b58bd12b691/0000010a--a51155e496";
void print_usage(const char *argv0) {
std::cerr
<< "Usage: " << argv0 << " [--layout <layout>] [options] [route]\n"
<< "\n"
<< "Options:\n"
<< " --demo\n"
<< " --data-dir <dir>\n"
<< " --stream\n"
<< " --address <host>\n"
<< " --buffer-seconds <seconds>\n"
<< " --width <pixels>\n"
<< " --height <pixels>\n"
<< " --output <png>\n"
<< " --show\n"
<< " --sync-load\n"
<< "\n"
<< "Examples:\n"
<< " " << argv0 << "\n"
<< " " << argv0 << " --demo\n"
<< " " << argv0 << " --layout longitudinal --demo\n"
<< " " << argv0 << " --layout longitudinal --demo --output /tmp/longitudinal.png\n"
<< " " << argv0 << " --stream --show\n"
<< " " << argv0 << " --stream --address 192.168.60.52 --buffer-seconds 45 --show\n";
}
bool parse_int(const char *value, int *out) {
char *end = nullptr;
const long parsed = std::strtol(value, &end, 10);
if (end == nullptr || *end != '\0') return false;
*out = static_cast<int>(parsed);
return true;
}
bool parse_double(const char *value, double *out) {
char *end = nullptr;
const double parsed = std::strtod(value, &end);
if (end == nullptr || *end != '\0') return false;
*out = parsed;
return true;
}
} // namespace
int main(int argc, char *argv[]) {
Options options;
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
const auto require_value = [&](const char *flag) -> const char * {
if (i + 1 >= argc) {
std::cerr << "Missing value for " << flag << "\n";
print_usage(argv[0]);
std::exit(2);
}
return argv[++i];
};
if (arg == "--layout") {
options.layout = require_value("--layout");
} else if (arg == "--demo") {
options.route_name = DEMO_ROUTE;
} else if (arg == "--data-dir") {
options.data_dir = require_value("--data-dir");
} else if (arg == "--stream") {
options.stream = true;
} else if (arg == "--address") {
options.stream_address = require_value("--address");
} else if (arg == "--buffer-seconds") {
if (!parse_double(require_value("--buffer-seconds"), &options.stream_buffer_seconds)) {
std::cerr << "Invalid buffer seconds\n";
return 2;
}
} else if (arg == "--output") {
options.output_path = require_value("--output");
} else if (arg == "--width") {
if (!parse_int(require_value("--width"), &options.width)) {
std::cerr << "Invalid width\n";
return 2;
}
} else if (arg == "--height") {
if (!parse_int(require_value("--height"), &options.height)) {
std::cerr << "Invalid height\n";
return 2;
}
} else if (arg == "--show") {
options.show = true;
} else if (arg == "--sync-load") {
options.sync_load = true;
} else if (arg == "--help" || arg == "-h") {
print_usage(argv[0]);
return 0;
} else if (!arg.empty() && arg[0] != '-' && options.route_name.empty()) {
options.route_name = arg;
} else {
std::cerr << "Unknown argument: " << arg << "\n";
print_usage(argv[0]);
return 2;
}
}
if (options.output_path.empty() && !options.show) {
options.show = true;
}
if (options.width <= 0 || options.height <= 0) {
std::cerr << "Width and height must be positive\n";
return 2;
}
if (options.stream && !options.route_name.empty()) {
std::cerr << "Route/file mode and --stream are mutually exclusive\n";
return 2;
}
if (options.stream_buffer_seconds <= 0.0) {
std::cerr << "Buffer seconds must be positive\n";
return 2;
}
return run(options);
}
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include <condition_variable>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
struct GpsTrace;
struct GeoBounds {
double south = 0.0;
double west = 0.0;
double north = 0.0;
double east = 0.0;
bool valid() const {
return south < north && west < east;
}
};
struct RouteBasemap;
struct MapCacheStats {
uint64_t bytes = 0;
size_t files = 0;
};
class MapDataManager {
public:
MapDataManager();
~MapDataManager();
MapDataManager(const MapDataManager &) = delete;
MapDataManager &operator=(const MapDataManager &) = delete;
void pump();
void ensureTrace(const GpsTrace &trace);
void clearCache();
bool loading() const;
const RouteBasemap *current() const;
MapCacheStats cacheStats() const;
private:
struct Request {
std::string key;
GeoBounds bounds;
std::string query;
};
void run();
mutable std::mutex mutex_;
std::condition_variable cv_;
bool stopping_ = false;
std::unique_ptr<Request> pending_;
std::unique_ptr<Request> active_;
std::unique_ptr<RouteBasemap> completed_;
std::unique_ptr<RouteBasemap> current_;
std::thread worker_;
};
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
import json
import sys
import textwrap
import traceback
import numpy as np
def _load_manifest(path: str) -> dict:
with open(path, encoding="utf-8") as f:
return json.load(f)
def _load_vector(path: str) -> np.ndarray:
return np.fromfile(path, dtype=np.float64)
def _write_vector(path: str, values: np.ndarray) -> None:
np.asarray(values, dtype=np.float64).tofile(path)
def _resample_to_reference(ref_t: np.ndarray, src_t: np.ndarray, src_v: np.ndarray) -> np.ndarray:
ref_t = np.asarray(ref_t, dtype=np.float64).reshape(-1)
src_t = np.asarray(src_t, dtype=np.float64).reshape(-1)
src_v = np.asarray(src_v, dtype=np.float64).reshape(-1)
if ref_t.size == 0 or src_t.size == 0 or src_v.size == 0:
return np.empty_like(ref_t)
indices = np.searchsorted(src_t, ref_t, side="right") - 1
indices = np.clip(indices, 0, src_v.size - 1)
return src_v[indices]
def _evaluate_user_code(code: str, env: dict):
stripped = code.strip()
if not stripped:
raise ValueError("Function body is empty")
expr = stripped
if expr.startswith("return "):
expr = expr[7:].strip()
try:
return eval(expr, env, env)
except SyntaxError:
pass
function_src = "def __jotpluggler_eval__():\n" + textwrap.indent(code, " ")
exec(function_src, env, env)
return env["__jotpluggler_eval__"]()
def main() -> int:
if len(sys.argv) != 6:
print("usage: math_eval.py <manifest.json> <globals.py> <code.py> <out_t.bin> <out_v.bin>", file=sys.stderr)
return 2
manifest_path, globals_path, code_path, out_t_path, out_v_path = sys.argv[1:6]
manifest = _load_manifest(manifest_path)
series_t = {}
series_v = {}
for entry in manifest.get("series", []):
path = entry["path"]
series_t[path] = _load_vector(entry["t"])
series_v[path] = _load_vector(entry["v"])
first_path = manifest.get("linked_source") or None
def remember(path: str) -> None:
nonlocal first_path
if first_path is None:
first_path = path
def t(path: str) -> np.ndarray:
remember(path)
return series_t[path]
def v(path: str) -> np.ndarray:
remember(path)
return series_v[path]
additional_sources = list(manifest.get("additional_sources", []))
linked_source = manifest.get("linked_source") or ""
paths = list(manifest.get("paths", []))
env = {
"__builtins__": __builtins__,
"np": np,
"t": t,
"v": v,
"paths": paths,
"linked_source": linked_source,
"additional_sources": additional_sources,
}
reference_time = None
if linked_source:
reference_time = series_t[linked_source]
env["time"] = reference_time
env["value"] = series_v[linked_source]
for i, path in enumerate(additional_sources, start=1):
if reference_time is None:
env[f"t{i}"] = series_t[path]
env[f"v{i}"] = series_v[path]
else:
env[f"t{i}"] = reference_time
env[f"v{i}"] = _resample_to_reference(reference_time, series_t[path], series_v[path])
with open(globals_path, encoding="utf-8") as f:
globals_code = f.read()
if globals_code.strip():
exec(globals_code, env, env)
with open(code_path, encoding="utf-8") as f:
user_code = f.read()
result = _evaluate_user_code(user_code, env)
if isinstance(result, tuple) and len(result) == 2:
result_t, result_v = result
else:
if first_path is None:
raise ValueError("No reference series found. Set an input timeseries or return (times, values).")
result_t = series_t[first_path]
result_v = result
result_t = np.asarray(result_t, dtype=np.float64).reshape(-1)
result_v = np.asarray(result_v, dtype=np.float64).reshape(-1)
if result_t.size == 0 or result_v.size == 0:
raise ValueError("Custom series returned an empty result")
if result_t.shape != result_v.shape:
raise ValueError(f"Time/value arrays must have the same shape, got {result_t.shape} and {result_v.shape}")
_write_vector(out_t_path, result_t)
_write_vector(out_v_path, result_v)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as err:
traceback.print_exc()
raise SystemExit(1) from err
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
#include "tools/jotpluggler/internal.h"
#include "imgui_impl_glfw.h"
#include "imgui_impl_opengl3.h"
#include "imgui_impl_opengl3_loader.h"
#include <GLFW/glfw3.h>
namespace fs = std::filesystem;
void draw_fps_overlay(const UiState &state, float top_offset) {
if (!state.show_fps_overlay) {
return;
}
ImGuiViewport *viewport = ImGui::GetMainViewport();
const ImGuiIO &io = ImGui::GetIO();
const float fps = io.Framerate;
const std::string label = util::string_format("%.1f fps", fps);
const ImVec2 padding(10.0f, 8.0f);
const ImVec2 margin(12.0f, 10.0f);
app_push_mono_font();
ImFont *font = ImGui::GetFont();
const float font_size = ImGui::GetFontSize();
const ImVec2 text_size = ImGui::CalcTextSize(label.c_str());
app_pop_mono_font();
const ImVec2 size(text_size.x + padding.x * 2.0f, text_size.y + padding.y * 2.0f);
const ImVec2 pos(viewport->Pos.x + viewport->Size.x - size.x - margin.x,
viewport->Pos.y + top_offset + margin.y);
ImDrawList *draw_list = ImGui::GetForegroundDrawList(viewport);
const ImVec2 max(pos.x + size.x, pos.y + size.y);
draw_list->AddRectFilled(pos, max, ImGui::GetColorU32(color_rgb(248, 249, 251, 0.92f)), 4.0f);
draw_list->AddRect(pos, max, ImGui::GetColorU32(color_rgb(182, 188, 196, 0.95f)), 4.0f);
draw_list->AddText(font, font_size, ImVec2(pos.x + padding.x, pos.y + padding.y),
ImGui::GetColorU32(color_rgb(57, 62, 69)), label.c_str(), nullptr);
}
void render_layout(AppSession *session, UiState *state, bool show_camera_feed) {
if (!state->fps_overlay_initialized) {
state->show_fps_overlay = false;
state->fps_overlay_initialized = true;
}
ensure_shared_range(state, *session);
if (state->follow_latest) {
update_follow_range(state, *session);
state->suppress_range_side_effects = true;
} else {
clamp_shared_range(state, *session);
}
const bool ctrl = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeySuper;
const bool shift = ImGui::GetIO().KeyShift;
if (!ImGui::GetIO().WantTextInput && ctrl && ImGui::IsKeyPressed(ImGuiKey_Z, false)) {
if (shift) {
apply_redo(session, state);
} else {
apply_undo(session, state);
}
}
if (!ImGui::GetIO().WantTextInput && ctrl && ImGui::IsKeyPressed(ImGuiKey_F, false)) {
state->open_find_signal = true;
}
if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false)) {
step_tracker(state, -1.0);
}
if (ImGui::IsKeyPressed(ImGuiKey_RightArrow, false)) {
step_tracker(state, 1.0);
}
if (!ImGui::GetIO().WantTextInput && ImGui::IsKeyPressed(ImGuiKey_Space, false)) {
state->playback_playing = !state->playback_playing;
}
advance_playback(state, *session);
CameraFeedView *sidebar_camera = session->pane_camera_feeds[static_cast<size_t>(sidebar_preview_camera_view(*session))].get();
if (show_camera_feed && sidebar_camera != nullptr && state->has_tracker_time) {
sidebar_camera->update(state->tracker_time);
}
const float menu_height = draw_main_menu_bar(session, state);
UiMetrics ui = compute_ui_metrics(ImGui::GetMainViewport()->Size, menu_height, state->sidebar_width);
if (state->browser_nodes_dirty) {
rebuild_browser_nodes(session, state);
state->browser_nodes_dirty = false;
}
state->sidebar_width = ui.sidebar_width;
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) {
draw_pane_windows(session, state);
}
draw_status_bar(*session, ui, state);
draw_popups(session, state);
draw_fps_overlay(*state, menu_height);
}
void save_framebuffer_png(const fs::path &output_path, int width, int height) {
ensure_parent_dir(output_path);
if (width <= 0 || height <= 0) throw std::runtime_error("Invalid framebuffer size");
std::vector<uint8_t> pixels(static_cast<size_t>(width) * static_cast<size_t>(height) * 4U, 0);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
const fs::path ppm_path = output_path.parent_path() / (output_path.stem().string() + ".ppm");
std::string ppm = util::string_format("P6\n%d %d\n255\n", width, height);
ppm.reserve(ppm.size() + static_cast<size_t>(width) * static_cast<size_t>(height) * 3U);
for (int y = height - 1; y >= 0; --y) {
for (int x = 0; x < width; ++x) {
const size_t index = static_cast<size_t>((y * width + x) * 4);
ppm.append(reinterpret_cast<const char *>(&pixels[index]), 3);
}
}
write_file_or_throw(ppm_path, ppm.data(), ppm.size());
const std::string command = "convert " + shell_quote(ppm_path.string()) + " " + shell_quote(output_path.string());
run_system_or_throw(command, "image conversion");
fs::remove(ppm_path);
}
void render_frame(GLFWwindow *window, AppSession *session, UiState *state, const fs::path *capture_path) {
glfwPollEvents();
int framebuffer_width = 0;
int framebuffer_height = 0;
glfwGetFramebufferSize(window, &framebuffer_width, &framebuffer_height);
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();
if (state->request_save_layout) {
if (session->layout_path.empty()) {
state->open_save_layout = true;
} else {
save_layout(session, state, session->layout_path.string());
}
state->request_save_layout = false;
}
if (state->request_reset_layout) {
reset_layout(session, state);
state->request_reset_layout = false;
}
poll_async_route_load(session, state);
if (session->data_mode == SessionDataMode::Stream && session->stream_poller) {
StreamExtractBatch batch;
std::string error_text;
if (session->stream_poller->consume(&batch, &error_text)) {
if (!error_text.empty()) {
state->error_text = error_text;
state->open_error_popup = true;
state->status_text = "Stream disconnected";
} else {
apply_stream_batch(session, state, std::move(batch));
}
}
}
const bool show_camera = capture_path == nullptr && session->data_mode != SessionDataMode::Stream;
render_layout(session, state, show_camera);
ImGui::Render();
if (state->request_close) {
glfwSetWindowShouldClose(window, GLFW_TRUE);
state->request_close = false;
}
glViewport(0, 0, framebuffer_width, framebuffer_height);
glClearColor(227.0f / 255.0f, 229.0f / 255.0f, 233.0f / 255.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
if (capture_path != nullptr) {
save_framebuffer_png(*capture_path, framebuffer_width, framebuffer_height);
}
glfwSwapBuffers(window);
state->suppress_range_side_effects = false;
}
File diff suppressed because it is too large Load Diff
+773
View File
@@ -0,0 +1,773 @@
#include "tools/jotpluggler/internal.h"
#include "imgui_internal.h"
#include <array>
#include <cmath>
#include <cstdlib>
namespace fs = std::filesystem;
const RouteSeries *app_find_route_series(const AppSession &session, const std::string &path) {
auto it = session.series_by_path.find(path);
return it == session.series_by_path.end() ? nullptr : it->second;
}
void sync_camera_feeds(AppSession *session) {
for (size_t i = 0; i < kCameraViewSpecs.size(); ++i) {
if (session->pane_camera_feeds[i]) {
session->pane_camera_feeds[i]->setCameraIndex(session->route_data.*(kCameraViewSpecs[i].route_member), kCameraViewSpecs[i].view);
}
}
}
void apply_route_data(AppSession *session, UiState *state, RouteData route_data) {
if (!route_data.route_id.empty()) {
session->route_id = route_data.route_id;
} else if (session->route_name.empty() && session->data_mode == SessionDataMode::Route) {
session->route_id = {};
}
session->route_data = std::move(route_data);
rebuild_route_index(session);
rebuild_browser_nodes(session, state);
state->browser_nodes_dirty = false;
refresh_all_custom_curves(session, state);
sync_camera_feeds(session);
state->has_shared_range = false;
state->has_tracker_time = false;
reset_shared_range(state, *session);
}
bool restore_undo_layout(AppSession *session, UiState *state, const SketchLayout &layout, const char *status_text) {
session->layout = layout;
cancel_rename_tab(state);
state->custom_series.request_select = false;
state->active_tab_index = std::clamp(layout.current_tab_index, 0, std::max(0, static_cast<int>(layout.tabs.size()) - 1));
state->requested_tab_index = state->active_tab_index;
sync_ui_state(state, session->layout);
mark_all_docks_dirty(state);
const bool autosave_ok = autosave_layout(session, state);
if (autosave_ok) {
state->status_text = status_text;
}
return autosave_ok;
}
bool apply_undo(AppSession *session, UiState *state) {
if (!state->undo.can_undo()) {
return false;
}
return restore_undo_layout(session, state, state->undo.undo(), "Undo");
}
bool apply_redo(AppSession *session, UiState *state) {
if (!state->undo.can_redo()) {
return false;
}
return restore_undo_layout(session, state, state->undo.redo(), "Redo");
}
std::optional<std::pair<double, double>> tab_default_x_range(const WorkspaceTab &tab) {
bool found = false;
double min_value = 0.0;
double max_value = 1.0;
for (const Pane &pane : tab.panes) {
if (!pane.range.valid || pane.range.right <= pane.range.left) continue;
if (!found) {
min_value = pane.range.left;
max_value = pane.range.right;
found = true;
} else {
min_value = std::min(min_value, pane.range.left);
max_value = std::max(max_value, pane.range.right);
}
}
if (!found) return std::nullopt;
return std::make_pair(min_value, max_value);
}
bool infer_stream_follow_state(const UiState &state, const AppSession &session) {
if (session.data_mode != SessionDataMode::Stream || !state.has_shared_range || !session.route_data.has_time_range) {
return false;
}
const double target_span = std::max(MIN_HORIZONTAL_ZOOM_SECONDS, session.stream_buffer_seconds);
const double current_span = std::max(0.0, state.x_view_max - state.x_view_min);
const double edge_epsilon = std::max(0.05, target_span * 0.02);
return std::abs(state.x_view_max - state.route_x_max) <= edge_epsilon
&& std::abs(current_span - target_span) <= edge_epsilon;
}
void ensure_shared_range(UiState *state, const AppSession &session) {
if (session.route_data.has_time_range) {
state->route_x_min = session.route_data.x_min;
state->route_x_max = session.route_data.x_max;
} else {
state->route_x_min = 0.0;
state->route_x_max = 1.0;
}
if (state->has_shared_range) {
return;
}
if (session.data_mode == SessionDataMode::Stream) {
const double target_span = std::max(MIN_HORIZONTAL_ZOOM_SECONDS, session.stream_buffer_seconds);
if (session.route_data.has_time_range) {
state->x_view_max = state->route_x_max;
state->x_view_min = state->x_view_max - target_span;
} else {
state->x_view_min = 0.0;
state->x_view_max = target_span;
}
if (state->x_view_max <= state->x_view_min) {
state->x_view_max = state->x_view_min + 1.0;
}
state->has_shared_range = true;
if (!state->has_tracker_time || state->tracker_time < state->route_x_min || state->tracker_time > state->route_x_max) {
state->tracker_time = state->route_x_max;
state->has_tracker_time = session.route_data.has_time_range;
}
return;
}
if (const WorkspaceTab *tab = app_active_tab(session.layout, *state); tab != nullptr) {
if (std::optional<std::pair<double, double>> tab_range = tab_default_x_range(*tab); tab_range.has_value()) {
state->x_view_min = tab_range->first;
state->x_view_max = tab_range->second;
state->has_shared_range = true;
if (!state->has_tracker_time || state->tracker_time < state->route_x_min || state->tracker_time > state->route_x_max) {
state->tracker_time = state->route_x_min;
state->has_tracker_time = true;
}
return;
}
}
state->x_view_min = state->route_x_min;
state->x_view_max = state->route_x_max;
if (state->x_view_max <= state->x_view_min) {
state->x_view_max = state->x_view_min + 1.0;
}
state->has_shared_range = true;
if (!state->has_tracker_time || state->tracker_time < state->route_x_min || state->tracker_time > state->route_x_max) {
state->tracker_time = state->route_x_min;
state->has_tracker_time = true;
}
}
void clamp_shared_range(UiState *state, const AppSession &session) {
if (!state->has_shared_range) {
return;
}
const double min_span = MIN_HORIZONTAL_ZOOM_SECONDS;
double span = state->x_view_max - state->x_view_min;
if (span < min_span) {
const double center = 0.5 * (state->x_view_min + state->x_view_max);
span = min_span;
state->x_view_min = center - span * 0.5;
state->x_view_max = center + span * 0.5;
}
if (session.data_mode == SessionDataMode::Stream) {
if (session.route_data.has_time_range && state->x_view_max > state->route_x_max) {
state->x_view_min -= state->x_view_max - state->route_x_max;
state->x_view_max = state->route_x_max;
}
if (state->x_view_max <= state->x_view_min) {
state->x_view_max = state->x_view_min + min_span;
}
if (state->has_tracker_time && session.route_data.has_time_range) {
state->tracker_time = std::clamp(state->tracker_time, state->route_x_min, state->route_x_max);
}
if (session.route_data.has_time_range) {
state->follow_latest = infer_stream_follow_state(*state, session);
}
return;
}
if (state->route_x_max > state->route_x_min) {
if (state->x_view_min < state->route_x_min) {
state->x_view_max += state->route_x_min - state->x_view_min;
state->x_view_min = state->route_x_min;
}
if (state->x_view_max > state->route_x_max) {
state->x_view_min -= state->x_view_max - state->route_x_max;
state->x_view_max = state->route_x_max;
}
if (state->x_view_min < state->route_x_min) {
state->x_view_min = state->route_x_min;
}
if (state->x_view_max <= state->x_view_min) {
state->x_view_max = std::min(state->route_x_max, state->x_view_min + min_span);
}
}
if (state->has_tracker_time) {
state->tracker_time = std::clamp(state->tracker_time, state->route_x_min, state->route_x_max);
}
}
void reset_shared_range(UiState *state, const AppSession &session) {
state->has_shared_range = false;
ensure_shared_range(state, session);
clamp_shared_range(state, session);
}
void update_follow_range(UiState *state, const AppSession &session) {
if (!state->follow_latest || !state->has_shared_range) {
return;
}
const double span = session.data_mode == SessionDataMode::Stream
? std::max(MIN_HORIZONTAL_ZOOM_SECONDS, session.stream_buffer_seconds)
: std::max(MIN_HORIZONTAL_ZOOM_SECONDS, state->x_view_max - state->x_view_min);
const double route_span = state->route_x_max - state->route_x_min;
if (route_span <= 0.0) {
return;
}
state->x_view_max = state->route_x_max;
state->x_view_min = state->x_view_max - span;
clamp_shared_range(state, session);
}
void advance_playback(UiState *state, const AppSession &session) {
if (!state->playback_playing || !state->has_shared_range || state->route_x_max <= state->route_x_min) {
return;
}
const double delta = std::max(0.0, static_cast<double>(ImGui::GetIO().DeltaTime)) * state->playback_rate;
const double view_span = std::max(MIN_HORIZONTAL_ZOOM_SECONDS, state->x_view_max - state->x_view_min);
const double loop_min = state->playback_loop
? std::clamp(state->x_view_min, state->route_x_min, state->route_x_max)
: state->route_x_min;
const double loop_max = state->playback_loop
? std::clamp(state->x_view_max, state->route_x_min, state->route_x_max)
: state->route_x_max;
state->tracker_time += delta;
if (state->tracker_time >= loop_max) {
if (state->playback_loop) {
state->tracker_time = loop_min;
} else {
state->tracker_time = state->route_x_max;
state->playback_playing = false;
}
}
if (!state->playback_loop) {
constexpr double kScrollStartFraction = 0.70;
const double scroll_anchor = state->x_view_min + view_span * kScrollStartFraction;
if (state->tracker_time > scroll_anchor && state->x_view_max < state->route_x_max) {
state->x_view_min = state->tracker_time - view_span * kScrollStartFraction;
state->x_view_max = state->x_view_min + view_span;
clamp_shared_range(state, session);
} else if (state->tracker_time < state->x_view_min || state->tracker_time > state->x_view_max) {
state->x_view_min = state->tracker_time - view_span * 0.5;
state->x_view_max = state->x_view_min + view_span;
clamp_shared_range(state, session);
}
}
}
void step_tracker(UiState *state, double direction) {
if (!state->has_shared_range) {
return;
}
state->tracker_time += direction * std::max(0.001, state->playback_step);
state->tracker_time = std::clamp(state->tracker_time, state->route_x_min, state->route_x_max);
}
const char *log_selector_name(LogSelector selector) {
static constexpr const char *kLabels[] = {"a", "r", "q"};
const size_t index = static_cast<size_t>(selector);
return index < std::size(kLabels) ? kLabels[index] : kLabels[0];
}
const char *log_selector_description(LogSelector selector) {
static constexpr const char *kLabels[] = {
"any of rlog or qlog",
"rlog only",
"qlog only",
};
const size_t index = static_cast<size_t>(selector);
return index < std::size(kLabels) ? kLabels[index] : kLabels[0];
}
std::string shorten_route_part(std::string_view text, size_t keep) {
if (text.size() <= keep) {
return std::string(text);
}
return std::string(text.substr(0, keep));
}
bool parse_slice_spec(std::string_view text, int *begin, int *end) {
const auto parse_nonnegative = [](std::string_view value, int *out) {
if (value.empty()) return false;
char *end_ptr = nullptr;
const long parsed = std::strtol(std::string(value).c_str(), &end_ptr, 10);
if (end_ptr == nullptr || *end_ptr != '\0' || parsed < 0) {
return false;
}
*out = static_cast<int>(parsed);
return true;
};
const std::string trimmed = util::strip(std::string(text));
if (trimmed.empty()) {
return false;
}
const size_t colon = trimmed.find(':');
int parsed_begin = 0;
if (!parse_nonnegative(trimmed.substr(0, colon), &parsed_begin)) {
return false;
}
int parsed_end = parsed_begin;
if (colon != std::string::npos) {
const std::string end_text = trimmed.substr(colon + 1);
if (end_text.empty()) {
parsed_end = -1;
} else if (!parse_nonnegative(end_text, &parsed_end) || parsed_end < parsed_begin) {
return false;
}
}
*begin = parsed_begin;
*end = parsed_end;
return true;
}
std::string format_duration_short(double seconds) {
const double clamped = std::max(0.0, seconds);
const int total_ms = static_cast<int>(std::round(clamped * 1000.0));
const int minutes = total_ms / 60000;
const int rem_ms = total_ms % 60000;
const int secs = rem_ms / 1000;
const int millis = rem_ms % 1000;
return util::string_format("%d:%02d.%03d", minutes, secs, millis);
}
bool apply_route_identifier(AppSession *session, UiState *state, const RouteIdentifier &route_id, const char *status_text) {
if (route_id.empty()) {
return false;
}
if (!reload_session(session, state, route_id.full_spec(), session->data_dir)) {
return false;
}
state->status_text = status_text;
return true;
}
bool apply_route_slice_change(AppSession *session, UiState *state, std::string_view slice_text) {
int begin = 0;
int end = 0;
if (!parse_slice_spec(slice_text, &begin, &end)) {
state->error_text = "Slice must be N or N:M.";
state->open_error_popup = true;
return false;
}
RouteIdentifier next = session->route_id;
next.slice_begin = begin;
next.slice_end = end;
next.slice_explicit = true;
return apply_route_identifier(session, state, next, "Updated route slice");
}
bool apply_route_selector_change(AppSession *session, UiState *state, LogSelector selector) {
RouteIdentifier next = session->route_id;
next.selector = selector;
next.selector_explicit = true;
return apply_route_identifier(session, state, next, "Updated log selector");
}
ImU32 route_chip_part_color(int part_index, bool explicit_part) {
constexpr std::array<std::array<int, 3>, 4> BASE = {{
{70, 96, 126}, // dongle
{100, 86, 148}, // log id
{72, 112, 86}, // slice
{156, 104, 38}, // selector
}};
const std::array<int, 3> &base = BASE[static_cast<size_t>(std::clamp(part_index, 0, 3))];
if (explicit_part) {
return ImGui::GetColorU32(color_rgb(base[0], base[1], base[2]));
}
const int gray = 144;
return ImGui::GetColorU32(color_rgb((base[0] + gray) / 2, (base[1] + gray) / 2, (base[2] + gray) / 2));
}
bool draw_route_chip_text_button(const char *id,
std::string_view text,
ImVec2 pos,
ImU32 color,
ImDrawList *draw_list,
const char *tooltip = nullptr) {
const ImVec2 size = ImGui::CalcTextSize(text.data(), text.data() + text.size());
ImGui::SetCursorScreenPos(pos);
ImGui::InvisibleButton(id, size);
const bool hovered = ImGui::IsItemHovered();
if (hovered) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
draw_list->AddRectFilled(ImVec2(pos.x - 5.0f, pos.y - 1.0f),
ImVec2(pos.x + size.x + 5.0f, pos.y + size.y + 2.0f),
ImGui::GetColorU32(color_rgb(225, 231, 239, 0.95f)), 0.0f);
}
draw_list->AddText(pos, color, text.data(), text.data() + text.size());
if (tooltip != nullptr && ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) {
ImGui::BeginTooltip();
ImGui::TextUnformatted(tooltip);
ImGui::EndTooltip();
}
return ImGui::IsItemClicked(ImGuiMouseButton_Left);
}
void draw_route_copy_feedback(UiState *state, ImDrawList *draw_list, ImVec2 chip_max) {
if (state->route_copy_feedback_text.empty()) {
return;
}
const double now = ImGui::GetTime();
if (now >= state->route_copy_feedback_until) {
state->route_copy_feedback_text.clear();
state->route_copy_feedback_until = 0.0;
return;
}
const float alpha = static_cast<float>(std::clamp((state->route_copy_feedback_until - now) / 1.1, 0.0, 1.0));
const ImVec2 text_size = ImGui::CalcTextSize(state->route_copy_feedback_text.c_str());
const ImVec2 pad(9.0f, 5.0f);
const ImVec2 bubble_min(chip_max.x - text_size.x - pad.x * 2.0f, chip_max.y + 7.0f);
const ImVec2 bubble_max(chip_max.x, bubble_min.y + text_size.y + pad.y * 2.0f);
draw_list->AddRectFilled(bubble_min, bubble_max,
ImGui::GetColorU32(color_rgb(46, 125, 80, 0.96f * alpha)), 7.0f);
draw_list->AddRect(bubble_min, bubble_max,
ImGui::GetColorU32(color_rgb(35, 96, 61, 0.9f * alpha)), 7.0f, 0, 1.0f);
draw_list->AddText(ImVec2(std::floor(bubble_min.x + pad.x), std::floor(bubble_min.y + pad.y)),
ImGui::GetColorU32(color_rgb(247, 251, 248, alpha)),
state->route_copy_feedback_text.c_str());
}
void draw_route_info_popup(AppSession *session, UiState *state, ImVec2 anchor) {
if (session->route_id.empty()) {
return;
}
ImGui::SetNextWindowPos(anchor, ImGuiCond_Appearing);
ImGui::SetNextWindowSizeConstraints(ImVec2(300.0f, 0.0f), ImVec2(420.0f, FLT_MAX));
if (!ImGui::BeginPopup("##route_info_popup",
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings)) {
return;
}
ImGui::TextUnformatted("Route Info");
ImGui::Separator();
app_push_mono_font();
ImGui::TextUnformatted(session->route_id.canonical().c_str());
app_pop_mono_font();
const char *copy_icon = icon::CLIPBOARD;
const char *link_icon = icon::BOX_ARROW_UP_RIGHT;
const std::string useradmin_label = std::string("Useradmin ") + link_icon;
const std::string connect_label = std::string("comma connect ") + link_icon;
if (ImGui::Button(copy_icon, ImVec2(34.0f, 26.0f))) {
ImGui::SetClipboardText(session->route_id.canonical().c_str());
state->status_text = "Copied route to clipboard";
state->route_copy_feedback_text = "Copied";
state->route_copy_feedback_until = ImGui::GetTime() + 1.1;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) {
ImGui::BeginTooltip();
ImGui::TextUnformatted("Copy route");
ImGui::EndTooltip();
}
ImGui::SameLine();
if (ImGui::Button(useradmin_label.c_str(), ImVec2(132.0f, 26.0f))) {
open_external_url(route_useradmin_url(session->route_id));
state->status_text = "Opened useradmin";
}
ImGui::SameLine();
if (ImGui::Button(connect_label.c_str(), ImVec2(156.0f, 26.0f))) {
open_external_url(route_connect_url(session->route_id));
state->status_text = "Opened comma connect";
}
ImGui::Spacing();
const int loaded_begin = session->route_id.available_begin;
const int loaded_end = session->route_id.available_end;
const int loaded_count = loaded_end >= loaded_begin ? (loaded_end - loaded_begin + 1) : 0;
ImGui::Text("Duration %s", format_duration_short(session->route_data.x_max - session->route_data.x_min).c_str());
ImGui::Text("Segments %s (%d)", session->route_id.display_slice().c_str(), loaded_count);
ImGui::Text("Selector %s", log_selector_description(session->route_id.selector));
if (!session->route_data.car_fingerprint.empty()) {
ImGui::TextWrapped("Car %s", session->route_data.car_fingerprint.c_str());
}
if (!session->route_data.dbc_name.empty()) {
ImGui::TextWrapped("DBC %s", session->route_data.dbc_name.c_str());
}
ImGui::EndPopup();
}
void draw_route_id_chip(AppSession *session, UiState *state) {
if (session->data_mode != SessionDataMode::Route || session->route_id.empty()) {
return;
}
ImGuiWindow *window = ImGui::GetCurrentWindow();
ImDrawList *draw_list = ImGui::GetWindowDrawList();
const RouteIdentifier &route_id = session->route_id;
app_push_bold_font();
const std::string dongle_text = shorten_route_part(route_id.dongle_id, 8);
const std::string log_text = shorten_route_part(route_id.log_id, 16);
const std::string slice_text = route_id.display_slice();
const std::string selector_text(1, route_id.selector_char());
const std::string sep_text = " / ";
const ImVec2 dongle_size = ImGui::CalcTextSize(dongle_text.c_str());
const ImVec2 log_size = ImGui::CalcTextSize(log_text.c_str());
const ImVec2 slice_size = state->editing_route_slice
? ImVec2(68.0f, ImGui::GetFrameHeight())
: ImGui::CalcTextSize(slice_text.c_str());
const ImVec2 selector_size = ImGui::CalcTextSize(selector_text.c_str());
const ImVec2 sep_size = ImGui::CalcTextSize(sep_text.c_str());
constexpr float chip_pad_x = 12.0f;
constexpr float info_size = 18.0f;
const float chip_h = 28.0f;
const float chip_w = chip_pad_x * 2.0f + dongle_size.x + sep_size.x + log_size.x + sep_size.x
+ slice_size.x + sep_size.x + selector_size.x + 10.0f + info_size;
const float menu_right = window->Pos.x + window->Size.x - 8.0f;
const float cursor_x = ImGui::GetCursorScreenPos().x + 4.0f;
const float chip_x = std::clamp(cursor_x, window->Pos.x + 48.0f, std::max(window->Pos.x + 48.0f, menu_right - chip_w));
const float chip_y = std::floor(window->Pos.y + std::max(0.0f, (window->Size.y - chip_h) * 0.5f));
const ImVec2 chip_min(chip_x, chip_y);
const ImVec2 chip_max(chip_x + chip_w, chip_y + chip_h);
const float text_y = std::floor(chip_y + std::max(0.0f, (chip_h - ImGui::GetTextLineHeight()) * 0.5f));
const ImU32 chip_bg = ImGui::GetColorU32(color_rgb(247, 249, 252));
const ImU32 chip_border = ImGui::GetColorU32(color_rgb(184, 191, 200));
const ImU32 sep = ImGui::GetColorU32(color_rgb(162, 170, 178));
draw_list->AddRectFilled(chip_min, chip_max, chip_bg, 0.0f);
draw_list->AddRect(chip_min, chip_max, chip_border, 0.0f, 0, 1.0f);
float x = chip_x + chip_pad_x;
const bool dongle_click = draw_route_chip_text_button(
"##route_dongle", dongle_text, ImVec2(x, text_y), route_chip_part_color(0, true), draw_list,
"Device identifier");
x += dongle_size.x;
draw_list->AddText(ImVec2(x, text_y), sep, sep_text.c_str());
x += sep_size.x;
const bool log_click = draw_route_chip_text_button(
"##route_log", log_text, ImVec2(x, text_y), route_chip_part_color(1, true), draw_list,
"Route identifier");
x += log_size.x;
draw_list->AddText(ImVec2(x, text_y), sep, sep_text.c_str());
x += sep_size.x;
if (state->editing_route_slice) {
ImGui::SetCursorScreenPos(ImVec2(x - 4.0f, chip_y + 1.0f));
ImGui::SetNextItemWidth(76.0f);
if (state->focus_route_slice_input) {
ImGui::SetKeyboardFocusHere();
state->focus_route_slice_input = false;
}
const bool applied = input_text_string("##route_slice_edit", &state->route_slice_buffer,
ImGuiInputTextFlags_EnterReturnsTrue);
const bool deactivated = ImGui::IsItemDeactivated();
const bool clicked_elsewhere = ImGui::IsMouseClicked(ImGuiMouseButton_Left)
&& !ImGui::IsItemHovered()
&& !ImGui::IsItemActive();
if (applied) {
if (apply_route_slice_change(session, state, state->route_slice_buffer)) {
state->editing_route_slice = false;
}
} else if (ImGui::IsKeyPressed(ImGuiKey_Escape)) {
state->editing_route_slice = false;
} else if (deactivated || clicked_elsewhere) {
const std::string trimmed = util::strip(state->route_slice_buffer);
if (trimmed != route_id.display_slice()) {
int begin = 0;
int end = 0;
if (parse_slice_spec(trimmed, &begin, &end)) {
apply_route_slice_change(session, state, trimmed);
} else {
state->status_text = "Canceled route slice edit";
}
}
state->editing_route_slice = false;
}
x += slice_size.x;
} else {
const bool slice_click = draw_route_chip_text_button(
"##route_slice", slice_text, ImVec2(x, text_y),
route_chip_part_color(2, route_id.slice_explicit), draw_list,
"Segment range");
if (slice_click) {
state->editing_route_slice = true;
state->focus_route_slice_input = true;
state->route_slice_buffer = route_id.display_slice();
}
x += slice_size.x;
}
draw_list->AddText(ImVec2(x, text_y), sep, sep_text.c_str());
x += sep_size.x;
const bool selector_click = draw_route_chip_text_button(
"##route_selector", selector_text, ImVec2(x, text_y),
route_chip_part_color(3, route_id.selector_explicit), draw_list,
"Log selector");
if (selector_click) {
ImGui::OpenPopup("##route_selector_popup");
}
x += selector_size.x + 10.0f;
const ImVec2 info_center(x + info_size * 0.5f, chip_y + chip_h * 0.5f);
ImGui::SetCursorScreenPos(ImVec2(x, chip_y + (chip_h - info_size) * 0.5f));
ImGui::InvisibleButton("##route_info_button", ImVec2(info_size, info_size));
const bool info_hovered = ImGui::IsItemHovered();
if (info_hovered) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
draw_list->AddCircleFilled(info_center, info_size * 0.5f,
ImGui::GetColorU32(info_hovered ? color_rgb(220, 229, 240) : color_rgb(239, 243, 248)));
draw_list->AddCircle(info_center, info_size * 0.5f, chip_border, 20, 1.0f);
const char *info_text = icon::INFO_CIRCLE;
const ImVec2 info_text_size = ImGui::CalcTextSize(info_text);
draw_list->AddText(ImVec2(std::floor(info_center.x - info_text_size.x * 0.5f),
std::floor(info_center.y - info_text_size.y * 0.5f)),
route_chip_part_color(0, true), info_text);
if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) {
ImGui::BeginTooltip();
ImGui::TextUnformatted("Route details");
ImGui::EndTooltip();
}
if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) {
ImGui::OpenPopup("##route_info_popup");
}
app_pop_bold_font();
if (dongle_click || log_click) {
ImGui::SetClipboardText(route_id.canonical().c_str());
state->status_text = "Copied route to clipboard";
state->route_copy_feedback_text = "Copied";
state->route_copy_feedback_until = ImGui::GetTime() + 1.1;
}
ImGui::SetNextWindowPos(ImVec2(chip_max.x - 60.0f, chip_max.y + 4.0f), ImGuiCond_Appearing);
if (ImGui::BeginPopup("##route_selector_popup")) {
for (LogSelector selector : {LogSelector::Auto, LogSelector::RLog, LogSelector::QLog}) {
const bool selected = route_id.selector == selector;
const std::string label = std::string(log_selector_name(selector)) + " " + log_selector_description(selector);
if (ImGui::Selectable(label.c_str(), selected) && !selected) {
apply_route_selector_change(session, state, selector);
}
if (selected) {
ImGui::SetItemDefaultFocus();
}
}
ImGui::EndPopup();
}
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));
}
std::string format_cache_bytes(uint64_t bytes) {
if (bytes >= (1ULL << 30)) {
return util::string_format("%.1f GiB", static_cast<double>(bytes) / static_cast<double>(1ULL << 30));
} else if (bytes >= (1ULL << 20)) {
return util::string_format("%.1f MiB", static_cast<double>(bytes) / static_cast<double>(1ULL << 20));
} else if (bytes >= (1ULL << 10)) {
return util::string_format("%.1f KiB", static_cast<double>(bytes) / static_cast<double>(1ULL << 10));
}
return util::string_format("%llu B", static_cast<unsigned long long>(bytes));
}
MapCacheStats directory_cache_stats(const fs::path &root) {
MapCacheStats stats;
std::error_code ec;
if (!fs::exists(root, ec)) {
return stats;
}
fs::recursive_directory_iterator it(root, fs::directory_options::skip_permission_denied, ec);
for (const fs::directory_entry &entry : it) {
if (ec) {
ec.clear();
continue;
}
const fs::file_status status = entry.symlink_status(ec);
if (ec || !fs::is_regular_file(status)) {
ec.clear();
continue;
}
const uintmax_t size = entry.file_size(ec);
if (!ec) {
stats.bytes += static_cast<uint64_t>(size);
++stats.files;
} else {
ec.clear();
}
}
return stats;
}
float draw_main_menu_bar(AppSession *session, UiState *state) {
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(7.0f, 5.0f));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(9.0f, 6.0f));
float height = ImGui::GetFrameHeight();
if (ImGui::BeginMainMenuBar()) {
if (ImGui::BeginMenu("File")) {
if (ImGui::MenuItem("Undo", "Ctrl+Z", false, state->undo.can_undo())) {
apply_undo(session, state);
}
if (ImGui::MenuItem("Redo", "Ctrl+Shift+Z", false, state->undo.can_redo())) {
apply_redo(session, state);
}
ImGui::Separator();
if (ImGui::MenuItem("Open Route...")) {
state->open_open_route = true;
}
if (ImGui::MenuItem("Stream...")) {
state->open_stream = true;
}
if (ImGui::MenuItem("Find Signal...", "Ctrl+F")) {
state->open_find_signal = true;
}
ImGui::Separator();
if (ImGui::MenuItem("New Layout")) {
start_new_layout(session, state);
}
if (ImGui::MenuItem("Load Layout...")) {
state->open_load_layout = true;
}
if (ImGui::MenuItem("Save Layout")) {
state->request_save_layout = true;
}
if (ImGui::MenuItem("Save Layout As...")) {
state->open_save_layout = true;
}
if (ImGui::MenuItem("Reset Layout")) {
state->request_reset_layout = true;
}
ImGui::Separator();
if (ImGui::MenuItem("Show DEPRECATED Fields", nullptr, state->show_deprecated_fields)) {
state->show_deprecated_fields = !state->show_deprecated_fields;
rebuild_browser_nodes(session, state);
}
if (ImGui::MenuItem("Show FPS", nullptr, state->show_fps_overlay)) {
state->show_fps_overlay = !state->show_fps_overlay;
}
if (ImGui::MenuItem("Preferences...")) {
state->open_preferences = true;
}
ImGui::Separator();
if (ImGui::MenuItem("Reset Plot View")) {
reset_shared_range(state, *session);
state->follow_latest = session->data_mode == SessionDataMode::Stream;
clamp_shared_range(state, *session);
state->suppress_range_side_effects = true;
state->status_text = "Plot view reset";
}
ImGui::Separator();
if (ImGui::MenuItem("Close")) {
state->request_close = true;
}
ImGui::EndMenu();
}
ImGui::SameLine(0.0f, 8.0f);
draw_route_id_chip(session, state);
height = ImGui::GetWindowSize().y;
ImGui::EndMainMenuBar();
}
ImGui::PopStyleVar(2);
return height;
}
+215
View File
@@ -0,0 +1,215 @@
#include "tools/jotpluggler/internal.h"
std::string dbc_combo_label(const AppSession &session) {
if (!session.dbc_override.empty()) return session.dbc_override;
if (!session.route_data.dbc_name.empty()) return "Auto: " + session.route_data.dbc_name;
return "Auto";
}
float timeline_time_to_x(double time_value, double route_min, double route_max, float x_min, float x_max) {
const double span = route_max - route_min;
if (span <= 0.0) {
return x_min;
}
const double ratio = (time_value - route_min) / span;
return x_min + static_cast<float>(ratio * static_cast<double>(x_max - x_min));
}
double timeline_x_to_time(float x, double route_min, double route_max, float x_min, float x_max) {
const float width = std::max(1.0f, x_max - x_min);
const float clamped_x = std::clamp(x, x_min, x_max);
const double ratio = static_cast<double>((clamped_x - x_min) / width);
return route_min + ratio * (route_max - route_min);
}
void reset_timeline_view(UiState *state, const AppSession &session) {
state->follow_latest = session.data_mode == SessionDataMode::Stream;
reset_shared_range(state, session);
}
void draw_timeline_bar_contents(const AppSession &session, UiState *state, float width) {
if (!session.route_data.has_time_range) {
ImGui::Dummy(ImVec2(width, TIMELINE_BAR_HEIGHT));
return;
}
const ImVec2 cursor = ImGui::GetCursorScreenPos();
const ImVec2 size(width, TIMELINE_BAR_HEIGHT);
const ImVec2 bar_min(cursor.x + 1.0f, cursor.y + 1.0f);
const ImVec2 bar_max(cursor.x + size.x - 1.0f, cursor.y + size.y - 1.0f);
const double route_min = state->route_x_min;
const double route_max = state->route_x_max;
const float vp_left = timeline_time_to_x(std::clamp(state->x_view_min, route_min, route_max),
route_min, route_max, bar_min.x, bar_max.x);
const float vp_right = timeline_time_to_x(std::clamp(state->x_view_max, route_min, route_max),
route_min, route_max, bar_min.x, bar_max.x);
ImGui::InvisibleButton("##timeline_button", size);
const bool hovered = ImGui::IsItemHovered();
const bool active = ImGui::IsItemActive();
const bool double_clicked = hovered && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left);
ImDrawList *draw_list = ImGui::GetWindowDrawList();
draw_list->AddRectFilled(bar_min, bar_max, timeline_entry_color(TimelineEntry::Type::None, 0.2f));
if (session.route_data.timeline.empty()) {
draw_list->AddRectFilled(ImVec2(vp_left, bar_min.y), ImVec2(vp_right, bar_max.y),
timeline_entry_color(TimelineEntry::Type::None, 1.0f));
} else {
for (const TimelineEntry &entry : session.route_data.timeline) {
float x0 = timeline_time_to_x(entry.start_time, route_min, route_max, bar_min.x, bar_max.x);
float x1 = timeline_time_to_x(entry.end_time, route_min, route_max, bar_min.x, bar_max.x);
x1 = std::max(x1, x0 + 1.0f);
draw_list->AddRectFilled(ImVec2(x0, bar_min.y), ImVec2(x1, bar_max.y),
timeline_entry_color(entry.type, 0.25f));
}
for (const TimelineEntry &entry : session.route_data.timeline) {
float x0 = std::max(timeline_time_to_x(entry.start_time, route_min, route_max, bar_min.x, bar_max.x), vp_left);
float x1 = std::min(std::max(timeline_time_to_x(entry.end_time, route_min, route_max, bar_min.x, bar_max.x), x0 + 1.0f), vp_right);
if (x1 <= x0) {
continue;
}
draw_list->AddRectFilled(ImVec2(x0, bar_min.y), ImVec2(x1, bar_max.y),
timeline_entry_color(entry.type, 1.0f));
}
}
draw_list->AddLine(ImVec2(vp_left, bar_min.y), ImVec2(vp_left, bar_max.y), IM_COL32(60, 70, 80, 200), 1.0f);
draw_list->AddLine(ImVec2(vp_right, bar_min.y), ImVec2(vp_right, bar_max.y), IM_COL32(60, 70, 80, 200), 1.0f);
if (state->has_tracker_time) {
const float cx = timeline_time_to_x(std::clamp(state->tracker_time, route_min, route_max),
route_min, route_max, bar_min.x, bar_max.x);
draw_list->AddLine(ImVec2(cx, bar_min.y), ImVec2(cx, bar_max.y), IM_COL32(220, 60, 50, 255), 1.5f);
}
draw_list->AddRect(bar_min, bar_max, IM_COL32(170, 178, 186, 255), 0.0f, 0, 1.0f);
const float edge_grab = 4.0f;
const float mouse_x = ImGui::GetIO().MousePos.x;
const double mouse_time = timeline_x_to_time(mouse_x, route_min, route_max, bar_min.x, bar_max.x);
if (double_clicked) {
reset_timeline_view(state, session);
} else if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
state->timeline_drag_anchor_time = mouse_time;
state->timeline_drag_anchor_x_min = state->x_view_min;
state->timeline_drag_anchor_x_max = state->x_view_max;
if (std::abs(mouse_x - vp_left) <= edge_grab) {
state->timeline_drag_mode = TimelineDragMode::ResizeLeft;
} else if (std::abs(mouse_x - vp_right) <= edge_grab) {
state->timeline_drag_mode = TimelineDragMode::ResizeRight;
} else if (mouse_x >= vp_left && mouse_x <= vp_right) {
state->timeline_drag_mode = TimelineDragMode::PanViewport;
} else {
state->timeline_drag_mode = TimelineDragMode::ScrubCursor;
state->tracker_time = std::clamp(mouse_time, route_min, route_max);
state->has_tracker_time = true;
}
}
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
state->timeline_drag_mode = TimelineDragMode::None;
} else if (active || state->timeline_drag_mode != TimelineDragMode::None) {
switch (state->timeline_drag_mode) {
case TimelineDragMode::ScrubCursor:
state->tracker_time = std::clamp(mouse_time, route_min, route_max);
state->has_tracker_time = true;
break;
case TimelineDragMode::PanViewport: {
const double delta = mouse_time - state->timeline_drag_anchor_time;
state->x_view_min = state->timeline_drag_anchor_x_min + delta;
state->x_view_max = state->timeline_drag_anchor_x_max + delta;
clamp_shared_range(state, session);
break;
}
case TimelineDragMode::ResizeLeft:
state->x_view_min = std::min(mouse_time, state->x_view_max - MIN_HORIZONTAL_ZOOM_SECONDS);
clamp_shared_range(state, session);
break;
case TimelineDragMode::ResizeRight:
state->x_view_max = std::max(mouse_time, state->x_view_min + MIN_HORIZONTAL_ZOOM_SECONDS);
clamp_shared_range(state, session);
break;
case TimelineDragMode::None:
break;
}
}
if (hovered) {
if (std::abs(mouse_x - vp_left) <= edge_grab || std::abs(mouse_x - vp_right) <= edge_grab) {
ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW);
} else if (mouse_x >= vp_left && mouse_x <= vp_right) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
ImGui::BeginTooltip();
ImGui::Text("t=%.1fs — %s", mouse_time, timeline_entry_label(timeline_type_at_time(session.route_data.timeline, mouse_time)));
ImGui::EndTooltip();
}
}
void draw_status_bar(const AppSession &session, const UiMetrics &ui, UiState *state) {
ImGui::SetNextWindowPos(ImVec2(ui.content_x, ui.status_bar_y));
ImGui::SetNextWindowSize(ImVec2(ui.content_w, STATUS_BAR_HEIGHT));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f));
ImGui::PushStyleColor(ImGuiCol_WindowBg, color_rgb(247, 248, 250));
ImGui::PushStyleColor(ImGuiCol_Border, color_rgb(188, 193, 199));
const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoSavedSettings;
if (ImGui::Begin("##status_bar", nullptr, flags)) {
draw_timeline_bar_contents(session, state, ui.content_w);
const float row_y = TIMELINE_BAR_HEIGHT + 8.0f;
ImGui::SetCursorPos(ImVec2(8.0f, row_y));
ImGui::BeginDisabled(!session.route_data.has_time_range);
ImGui::Checkbox("Loop", &state->playback_loop);
ImGui::SameLine(0.0f, 10.0f);
if (ImGui::Button(state->playback_playing ? "Pause" : "Play", ImVec2(56.0f, 0.0f))) {
state->playback_playing = !state->playback_playing;
}
ImGui::SameLine(0.0f, 10.0f);
if (ImGui::Button("Reset View", ImVec2(86.0f, 0.0f))) {
reset_timeline_view(state, session);
}
const float controls_end_x = ImGui::GetItemRectMax().x - ImGui::GetWindowPos().x;
ImGui::EndDisabled();
const char *status_text = state->status_text.empty() ? "Ready" : state->status_text.c_str();
const float status_x = controls_end_x + 16.0f;
ImGui::SetCursorPos(ImVec2(status_x, row_y + 2.0f));
ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(102, 110, 118));
ImGui::TextUnformatted(status_text);
ImGui::PopStyleColor();
}
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
}
void draw_sidebar_resizer(const UiMetrics &ui, UiState *state) {
constexpr float kHandleWidth = 14.0f;
ImGui::SetNextWindowPos(ImVec2(ui.sidebar_width - kHandleWidth * 0.5f, ui.top_offset));
ImGui::SetNextWindowSize(ImVec2(kHandleWidth, std::max(1.0f, ui.height - ui.top_offset)));
const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoSavedSettings |
ImGuiWindowFlags_NoBackground;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f));
if (ImGui::Begin("##sidebar_resizer", nullptr, flags)) {
ImGui::InvisibleButton("##sidebar_resizer_button", ImVec2(kHandleWidth, std::max(1.0f, ui.height - ui.top_offset)));
if (ImGui::IsItemHovered() || ImGui::IsItemActive()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW);
}
if (ImGui::IsItemActive()) {
const float max_width = std::min(SIDEBAR_MAX_WIDTH, ui.width * 0.6f);
state->sidebar_width = std::clamp(ImGui::GetIO().MousePos.x, SIDEBAR_MIN_WIDTH, max_width);
}
ImDrawList *draw_list = ImGui::GetWindowDrawList();
const ImVec2 origin = ImGui::GetWindowPos();
draw_list->AddLine(ImVec2(origin.x + kHandleWidth * 0.5f, origin.y),
ImVec2(origin.x + kHandleWidth * 0.5f, origin.y + std::max(1.0f, ui.height - ui.top_offset)),
IM_COL32(194, 198, 204, 255));
}
ImGui::End();
ImGui::PopStyleVar();
}
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
#include "tools/jotpluggler/internal.h"
#include <tuple>
template <typename Cmp, typename SeriesAccessor, typename LogAccessor>
std::optional<double> stream_batch_extreme_time(const StreamExtractBatch &batch,
Cmp cmp,
SeriesAccessor series_time,
LogAccessor log_time_fn) {
std::optional<double> result;
for (const RouteSeries &series : batch.series) {
if (!series.times.empty()) {
const double t = series_time(series);
result = result.has_value() ? cmp(*result, t) : t;
}
}
if (!batch.logs.empty()) {
const double t = log_time_fn(batch);
result = result.has_value() ? cmp(*result, t) : t;
}
if (!batch.timeline.empty()) {
const double t = cmp(batch.timeline.front().start_time, batch.timeline.back().end_time);
result = result.has_value() ? cmp(*result, t) : t;
}
for (const CanMessageData &message : batch.can_messages) {
if (!message.samples.empty()) {
const double t = cmp(message.samples.front().mono_time, message.samples.back().mono_time);
result = result.has_value() ? cmp(*result, t) : t;
}
}
return result;
}
std::optional<double> earliest_stream_batch_time(const StreamExtractBatch &batch) {
return stream_batch_extreme_time(batch,
[](double a, double b) { return std::min(a, b); },
[](const RouteSeries &s) { return s.times.front(); },
[](const StreamExtractBatch &b) { return b.logs.front().mono_time; });
}
std::optional<double> latest_stream_batch_time(const StreamExtractBatch &batch) {
return stream_batch_extreme_time(batch,
[](double a, double b) { return std::max(a, b); },
[](const RouteSeries &s) { return s.times.back(); },
[](const StreamExtractBatch &b) { return b.logs.back().mono_time; });
}
bool layout_has_custom_curves(const SketchLayout &layout) {
for (const WorkspaceTab &tab : layout.tabs) {
for (const Pane &pane : tab.panes) {
for (const Curve &curve : pane.curves) {
if (curve.custom_python.has_value()) return true;
}
}
}
return false;
}
void append_stream_timeline_entries(std::vector<TimelineEntry> *timeline, std::vector<TimelineEntry> entries) {
for (TimelineEntry &entry : entries) {
if (!timeline->empty() && timeline->back().type == entry.type) {
timeline->back().end_time = std::max(timeline->back().end_time, entry.end_time);
} else {
timeline->push_back(std::move(entry));
}
}
}
bool can_message_less(const CanMessageData &a, const CanMessageData &b) {
return std::make_tuple(a.id.service, a.id.bus, a.id.address)
< std::make_tuple(b.id.service, b.id.bus, b.id.address);
}
void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch batch) {
if (batch.has_time_offset) {
session->stream_time_offset = batch.time_offset;
}
if (!batch.car_fingerprint.empty()) {
session->route_data.car_fingerprint = batch.car_fingerprint;
}
if (!batch.dbc_name.empty()) {
session->route_data.dbc_name = batch.dbc_name;
}
if (!batch.enum_info.empty()) {
for (auto &[path, info] : batch.enum_info) {
session->route_data.enum_info[path] = std::move(info);
}
}
bool new_paths = false;
std::vector<RouteSeries> new_series;
std::vector<std::string> touched_paths;
touched_paths.reserve(batch.series.size());
for (RouteSeries &incoming : batch.series) {
touched_paths.push_back(incoming.path);
auto existing_it = session->series_by_path.find(incoming.path);
if (existing_it == session->series_by_path.end()) {
new_series.push_back(std::move(incoming));
new_paths = true;
continue;
}
RouteSeries &existing = *existing_it->second;
existing.times.insert(existing.times.end(), incoming.times.begin(), incoming.times.end());
existing.values.insert(existing.values.end(), incoming.values.begin(), incoming.values.end());
}
for (RouteSeries &series : new_series) {
session->route_data.paths.push_back(series.path);
session->route_data.series.push_back(std::move(series));
}
if (!batch.logs.empty()) {
std::sort(batch.logs.begin(), batch.logs.end(), [](const LogEntry &a, const LogEntry &b) {
return a.mono_time < b.mono_time;
});
const size_t old_size = session->route_data.logs.size();
session->route_data.logs.insert(session->route_data.logs.end(),
std::make_move_iterator(batch.logs.begin()),
std::make_move_iterator(batch.logs.end()));
if (old_size > 0 && session->route_data.logs.size() > old_size
&& session->route_data.logs[old_size - 1].mono_time > session->route_data.logs[old_size].mono_time) {
std::inplace_merge(session->route_data.logs.begin(),
session->route_data.logs.begin() + static_cast<ptrdiff_t>(old_size),
session->route_data.logs.end(),
[](const LogEntry &a, const LogEntry &b) {
return a.mono_time < b.mono_time;
});
}
}
if (!batch.timeline.empty()) {
append_stream_timeline_entries(&session->route_data.timeline, std::move(batch.timeline));
}
for (CanMessageData &incoming : batch.can_messages) {
auto it = std::lower_bound(session->route_data.can_messages.begin(),
session->route_data.can_messages.end(),
incoming,
can_message_less);
if (it == session->route_data.can_messages.end()
|| can_message_less(incoming, *it)
|| can_message_less(*it, incoming)) {
session->route_data.can_messages.insert(it, std::move(incoming));
} else {
it->samples.insert(it->samples.end(),
std::make_move_iterator(incoming.samples.begin()),
std::make_move_iterator(incoming.samples.end()));
}
}
if (new_paths) {
const size_t old_path_count = session->route_data.paths.size() - new_series.size();
std::sort(session->route_data.paths.begin() + static_cast<ptrdiff_t>(old_path_count), session->route_data.paths.end());
std::inplace_merge(session->route_data.paths.begin(),
session->route_data.paths.begin() + static_cast<ptrdiff_t>(old_path_count),
session->route_data.paths.end());
const size_t old_series_count = session->route_data.series.size() - new_series.size();
auto series_cmp = [](const RouteSeries &a, const RouteSeries &b) { return a.path < b.path; };
std::sort(session->route_data.series.begin() + static_cast<ptrdiff_t>(old_series_count),
session->route_data.series.end(), series_cmp);
std::inplace_merge(session->route_data.series.begin(),
session->route_data.series.begin() + static_cast<ptrdiff_t>(old_series_count),
session->route_data.series.end(), series_cmp);
session->route_data.roots = collect_route_roots_for_paths(session->route_data.paths);
rebuild_route_index(session);
rebuild_browser_nodes(session, state);
state->browser_nodes_dirty = false;
} else {
for (const std::string &path : touched_paths) {
auto series_it = session->series_by_path.find(path);
if (series_it == session->series_by_path.end() || series_it->second == nullptr) continue;
const bool enum_like = session->route_data.enum_info.find(path) != session->route_data.enum_info.end();
session->route_data.series_formats[path] = compute_series_format(series_it->second->values, enum_like);
}
}
const std::optional<double> earliest_time = earliest_stream_batch_time(batch);
const std::optional<double> latest_time = latest_stream_batch_time(batch);
if (earliest_time.has_value() && latest_time.has_value()) {
if (!session->route_data.has_time_range) {
session->route_data.x_min = *earliest_time;
session->route_data.x_max = *latest_time;
} else {
session->route_data.x_min = std::min(session->route_data.x_min, *earliest_time);
session->route_data.x_max = std::max(session->route_data.x_max, *latest_time);
}
session->route_data.has_time_range = true;
}
if (new_paths
|| std::find(touched_paths.begin(), touched_paths.end(), "/gpsLocationExternal/latitude") != touched_paths.end()
|| std::find(touched_paths.begin(), touched_paths.end(), "/gpsLocationExternal/longitude") != touched_paths.end()
|| std::find(touched_paths.begin(), touched_paths.end(), "/gpsLocationExternal/hasFix") != touched_paths.end()
|| std::find(touched_paths.begin(), touched_paths.end(), "/gpsLocationExternal/bearingDeg") != touched_paths.end()) {
rebuild_gps_trace(&session->route_data);
}
if (latest_time.has_value() && layout_has_custom_curves(session->layout)
&& *latest_time >= session->next_stream_custom_refresh_time) {
refresh_all_custom_curves(session, state);
session->next_stream_custom_refresh_time = *latest_time + 0.1;
}
if (state->follow_latest || !state->has_tracker_time) {
state->tracker_time = session->route_data.x_max;
state->has_tracker_time = session->route_data.has_time_range;
}
if (!state->has_shared_range) {
reset_shared_range(state, *session);
}
}
+59
View File
@@ -0,0 +1,59 @@
#include "tools/jotpluggler/util.h"
#include <cstdio>
#include <cstdlib>
#include <stdexcept>
#include <sys/wait.h>
std::string read_file_or_throw(const std::filesystem::path &path) {
const std::string contents = util::read_file(path.string());
if (!contents.empty() || std::filesystem::exists(path)) {
return contents;
}
throw std::runtime_error("Failed to read " + path.string());
}
void write_file_or_throw(const std::filesystem::path &path, const void *data, size_t size) {
ensure_parent_dir(path);
const std::string path_string = path.string();
const void *bytes = size == 0 ? static_cast<const void *>("") : data;
if (util::write_file(path_string.c_str(), bytes, size, O_WRONLY | O_CREAT | O_TRUNC) != 0) {
throw std::runtime_error("Failed to write " + path_string);
}
}
void write_file_or_throw(const std::filesystem::path &path, std::string_view contents) {
write_file_or_throw(path, contents.data(), contents.size());
}
void run_system_or_throw(const std::string &command, std::string_view action) {
const int ret = std::system(command.c_str());
if (ret != 0) {
throw std::runtime_error(util::string_format("%.*s failed with exit code %d",
static_cast<int>(action.size()), action.data(), ret));
}
}
CommandResult run_process_capture_output(const std::vector<std::string> &args) {
std::string command;
for (const std::string &arg : args) {
if (!command.empty()) command += ' ';
command += shell_quote(arg);
}
command += " 2>&1";
FILE *pipe = popen(command.c_str(), "r");
if (pipe == nullptr) {
throw std::runtime_error("popen() failed");
}
CommandResult result;
std::array<char, 4096> buf = {};
while (fgets(buf.data(), static_cast<int>(buf.size()), pipe) != nullptr) {
result.output += buf.data();
}
const int status = pclose(pipe);
result.exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 1;
return result;
}
+103
View File
@@ -0,0 +1,103 @@
#pragma once
#include "common/util.h"
#include "imgui.h"
#include <algorithm>
#include <array>
#include <cctype>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
inline ImVec4 color_rgb(int r, int g, int b, float alpha = 1.0f) {
return ImVec4(static_cast<float>(r) / 255.0f,
static_cast<float>(g) / 255.0f,
static_cast<float>(b) / 255.0f,
alpha);
}
inline ImVec4 color_rgb(const std::array<uint8_t, 3> &color, float alpha = 1.0f) {
return color_rgb(color[0], color[1], color[2], alpha);
}
inline std::string lowercase_copy(std::string_view value) {
std::string out(value);
std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return out;
}
inline int imgui_resize_callback(ImGuiInputTextCallbackData *data) {
if (data->EventFlag != ImGuiInputTextFlags_CallbackResize || data->UserData == nullptr) return 0;
auto *text = static_cast<std::string *>(data->UserData);
text->resize(static_cast<size_t>(data->BufTextLen));
data->Buf = text->data();
return 0;
}
inline bool input_text_string(const char *label,
std::string *text,
ImGuiInputTextFlags flags = 0) {
flags |= ImGuiInputTextFlags_CallbackResize;
return ImGui::InputText(label, text->data(), text->capacity() + 1,
flags, imgui_resize_callback, text);
}
inline bool input_text_with_hint_string(const char *label,
const char *hint,
std::string *text,
ImGuiInputTextFlags flags = 0) {
flags |= ImGuiInputTextFlags_CallbackResize;
return ImGui::InputTextWithHint(label, hint, text->data(), text->capacity() + 1,
flags, imgui_resize_callback, text);
}
inline bool input_text_multiline_string(const char *label,
std::string *text,
const ImVec2 &size = ImVec2(0.0f, 0.0f),
ImGuiInputTextFlags flags = 0) {
flags |= ImGuiInputTextFlags_CallbackResize;
return ImGui::InputTextMultiline(label, text->data(), text->capacity() + 1,
size, flags, imgui_resize_callback, text);
}
inline bool is_local_stream_address(std::string_view address) {
return address.empty() || address == "127.0.0.1" || address == "localhost";
}
inline void ensure_parent_dir(const std::filesystem::path &path) {
if (path.has_parent_path()) {
std::filesystem::create_directories(path.parent_path());
}
}
inline std::string shell_quote(std::string_view value) {
std::string quoted;
quoted.reserve(value.size() + 8);
quoted.push_back('\'');
for (char c : value) {
if (c == '\'') {
quoted += "'\\''";
} else {
quoted.push_back(c);
}
}
quoted.push_back('\'');
return quoted;
}
struct CommandResult {
int exit_code = 0;
std::string output;
};
std::string read_file_or_throw(const std::filesystem::path &path);
void write_file_or_throw(const std::filesystem::path &path, std::string_view contents);
void write_file_or_throw(const std::filesystem::path &path, const void *data, size_t size);
void run_system_or_throw(const std::string &command, std::string_view action);
CommandResult run_process_capture_output(const std::vector<std::string> &args);
+1
View File
@@ -0,0 +1 @@
/lateral_reports/
+42
View File
@@ -0,0 +1,42 @@
# Lateral Maneuvers Testing Tool
> [!WARNING]
> Use caution when using this tool.
Test your vehicle's lateral control tuning with this tool. The tool will test the vehicle's ability to follow a few lateral maneuvers and includes a tool to generate a report from the route.
## Instructions
1. Check out a development branch such as `master` on your comma device.
2. The full maneuver suite runs at 20 and 30 mph.
3. Enable "Lateral Maneuver Mode" in Settings > Developer on the device while offroad. Alternatively, set the parameter manually:
```sh
echo -n 1 > /data/params/d/LateralManeuverMode
```
4. Turn your vehicle back on. You will see "Lateral Maneuver Mode".
5. Ensure the area ahead is clear, as openpilot will command lateral acceleration steps in this mode. Once you are ready, set ACC manually to the target speed shown on screen and let openpilot stabilize lateral. After 1 seconds of steady straight driving, the maneuver will begin automatically. openpilot lateral control stays engaged between maneuvers normally while waiting for the next maneuver's readiness conditions. The maneuver will be aborted and repeated if speed is out of range, steering is touched or openpilot disengages.
6. When the testing is complete, you'll see an alert that says "Maneuvers Finished." Complete the route by pulling over and turning off the vehicle.
7. Visit https://connect.comma.ai and locate the route(s). They will stand out with lots of orange intervals in their timeline. Ensure "All logs" show as "uploaded."
![image](https://github.com/user-attachments/assets/cfe4c6d9-752f-4b24-b421-4b90a01933dc)
8. Gather the route ID and then run the report generator. The file will be exported to the same directory:
```sh
$ python tools/lateral_maneuvers/generate_report.py 98395b7c5b27882e/000001cc--5a73bde686
processing report for KIA_EV6
plotting maneuver: step right 20mph, runs: 3
plotting maneuver: step left 20mph, runs: 3
plotting maneuver: sine 0.5Hz 20mph, runs: 3
plotting maneuver: step right 30mph, runs: 3
Opening report: /home/batman/openpilot/tools/lateral_maneuvers/lateral_reports/KIA_EV6_98395b7c5b27882e_000001cc--5a73bde686.html
```
You can reach out on [Discord](https://discord.comma.ai) if you have any questions about these instructions or the tool itself.
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
import argparse
import base64
import io
import math
import numpy as np
import os
import webbrowser
from collections import defaultdict
from pathlib import Path
import matplotlib.pyplot as plt
from openpilot.common.utils import tabulate
from cereal import car
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.selfdrive.controls.lib.latcontrol_torque import LP_FILTER_CUTOFF_HZ
from openpilot.tools.lib.logreader import LogReader
from openpilot.system.hardware.hw import Paths
from openpilot.common.constants import CV
from openpilot.tools.longitudinal_maneuvers.generate_report import format_car_params
def lat_accel(curvature, v):
return curvature * max(v, 1.0) ** 2
def report(platform, route, _description, CP, ID, maneuvers):
output_path = Path(__file__).resolve().parent / "lateral_reports"
output_fn = output_path / f"{platform}_{route.replace('/', '_')}.html"
output_path.mkdir(exist_ok=True)
target_cross_times = defaultdict(list)
builder = [
"<style>summary { cursor: pointer; }\n td, th { padding: 8px; } </style>\n",
"<h1>Lateral maneuver report</h1>\n",
f"<h3>{platform}</h3>\n",
f"<h3>{route}</h3>\n",
f"<h3>{ID.gitCommit}, {ID.gitBranch}, {ID.gitRemote}</h3>\n",
]
if _description is not None:
builder.append(f"<h3>Description: {_description}</h3>\n")
builder.append(f"<details><summary><h3 style='display: inline-block;'>CarParams</h3></summary><pre>{format_car_params(CP)}</pre></details>\n")
builder.append('{ summary }') # to be replaced below
for description, runs in maneuvers:
# filter incomplete runs
completed_runs = [msgs for msgs in runs
if any(m.alertDebug.alertText1 == 'Complete' for m in msgs if m.which() == 'alertDebug')]
print(f'plotting maneuver: {description}')
if not completed_runs:
continue
builder.append("<div style='border-top: 1px solid #000; margin: 20px 0;'></div>\n")
builder.append(f"<h2>{description}</h2>\n")
for run, msgs in enumerate(completed_runs):
t_carControl, carControl = zip(*[(m.logMonoTime, m.carControl) for m in msgs if m.which() == 'carControl'], strict=True)
t_carState, carState = zip(*[(m.logMonoTime, m.carState) for m in msgs if m.which() == 'carState'], strict=True)
t_controlsState, controlsState = zip(*[(m.logMonoTime, m.controlsState) for m in msgs if m.which() == 'controlsState'], strict=True)
t_lateralPlan, lateralPlan = zip(*[(m.logMonoTime, m.lateralManeuverPlan) for m in msgs if m.which() == 'lateralManeuverPlan' and m.valid], strict=True)
t_carOutput, carOutput = zip(*[(m.logMonoTime, m.carOutput) for m in msgs if m.which() == 'carOutput'], strict=True)
# make time relative seconds
t_carControl = [(t - t_carControl[0]) / 1e9 for t in t_carControl]
t_carState = [(t - t_carState[0]) / 1e9 for t in t_carState]
t_controlsState = [(t - t_controlsState[0]) / 1e9 for t in t_controlsState]
t_lateralPlan = [(t - t_lateralPlan[0]) / 1e9 for t in t_lateralPlan]
t_carOutput = [(t - t_carOutput[0]) / 1e9 for t in t_carOutput]
# maneuver validity
latActive = [m.latActive for m in carControl]
maneuver_valid = all(latActive) and not any(cs.steeringPressed for cs in carState)
_open = 'open' if maneuver_valid else ''
title = f'Run #{int(run)+1}' + (' <span style="color: red">(invalid maneuver!)</span>' if not maneuver_valid else '')
builder.append(f"<details {_open}><summary><h3 style='display: inline-block;'>{title}</h3></summary>\n")
baseline_accel = lat_accel(controlsState[0].curvature, carState[0].vEgo)
v_ego = [m.vEgo for m in carState]
cross_markers = []
if description.startswith('sine'):
amplitude = max(abs(lat_accel(lp.desiredCurvature, v) - baseline_accel)
for lp, v in zip(lateralPlan, v_ego, strict=False))
threshold = amplitude * 0.5
builder.append('<h3 style="font-weight: normal">50% peak')
for t, cs, v in zip(t_controlsState, controlsState, v_ego, strict=False):
actual = lat_accel(cs.curvature, v) - baseline_accel
if abs(actual) > threshold:
builder.append(f', <strong>crossed in {t:.3f}s</strong>')
cross_markers.append((t, actual + baseline_accel))
if maneuver_valid:
target_cross_times[description].append(t)
break
else:
builder.append(', <strong>not crossed</strong>')
builder.append('</h3>')
if maneuver_valid:
target_cross_times.setdefault(description, [])
else:
action_targets = [(0, lat_accel(lateralPlan[0].desiredCurvature, v_ego[0]) - baseline_accel)]
for i in range(1, min(len(lateralPlan), len(v_ego))):
if abs(lateralPlan[i].desiredCurvature - lateralPlan[i - 1].desiredCurvature) > 0.001:
desired = lat_accel(lateralPlan[i].desiredCurvature, v_ego[i]) - baseline_accel
action_targets.append((i, desired))
for j, (start_i, act_target) in enumerate(action_targets):
start_time = t_lateralPlan[start_i]
end_time = t_lateralPlan[action_targets[j + 1][0]] if j + 1 < len(action_targets) else t_controlsState[-1]
builder.append(f'<h3 style="font-weight: normal">aTarget: {round(act_target, 1)} m/s^2')
prev_crossed = False
for t, cs, v in zip(t_controlsState, controlsState, v_ego, strict=False):
if not (start_time <= t <= end_time):
continue
actual_accel = lat_accel(cs.curvature, v) - baseline_accel
crossed = (0 < act_target < actual_accel) or (0 > act_target > actual_accel)
if crossed and prev_crossed:
cross_time = t - start_time
builder.append(f', <strong>crossed in {cross_time:.3f}s</strong>')
cross_markers.append((t, act_target + baseline_accel))
if maneuver_valid:
target_cross_times[description].append(cross_time)
break
prev_crossed = crossed
else:
builder.append(', <strong>not crossed</strong>')
builder.append('</h3>')
if maneuver_valid:
target_cross_times.setdefault(description, [])
plt.rcParams['font.size'] = 40
fig = plt.figure(figsize=(30, 30))
ax = fig.subplots(4, 1, sharex=True, gridspec_kw={'height_ratios': [5, 3, 3, 3]})
ax[0].grid(linewidth=4)
desired_lat_accel = [lat_accel(m.desiredCurvature, v) for m, v in zip(lateralPlan, v_ego, strict=False)]
if description.startswith('sine'):
ax[0].plot(t_lateralPlan[:len(desired_lat_accel)], desired_lat_accel, label='desired lat accel', linewidth=6)
else:
t_desired = [t_lateralPlan[0]] + t_lateralPlan[:len(desired_lat_accel)]
desired_lat_accel = [baseline_accel] + desired_lat_accel
ax[0].step(t_desired, desired_lat_accel, label='desired lat accel', linewidth=6, where='post')
actual_lat_accel = [lat_accel(cs.curvature, v) for cs, v in zip(controlsState, v_ego, strict=False)]
ax[0].plot(t_controlsState[:len(actual_lat_accel)], actual_lat_accel, label='actual lat accel', linewidth=6)
ax[0].set_ylabel('Lateral Accel (m/s^2)')
for ct, cv in cross_markers:
ax[0].plot(ct, cv, marker='o', markersize=50, markeredgewidth=7, markeredgecolor='black', markerfacecolor='None')
ax2 = ax[0].twinx()
if CP.steerControlType == car.CarParams.SteerControlType.angle:
ax2.plot(t_carOutput, [-m.actuatorsOutput.steeringAngleDeg for m in carOutput], 'C2', label='steer angle', linewidth=6)
else:
ax2.plot(t_carOutput, [-m.actuatorsOutput.torque for m in carOutput], 'C2', label='steer torque', linewidth=6)
h1, l1 = ax[0].get_legend_handles_labels()
h2, l2 = ax2.get_legend_handles_labels()
ax[0].legend(h1 + h2, l1 + l2, prop={'size': 30})
ax[1].grid(linewidth=4)
ax[1].plot(t_carState, [v * CV.MS_TO_MPH for v in v_ego], label='vEgo', linewidth=6)
ax[1].set_ylabel('Velocity (mph)')
ax[1].yaxis.set_major_formatter(plt.FormatStrFormatter('%.1f'))
ax[1].legend()
t_accel = np.array(t_controlsState[:len(actual_lat_accel)])
raw_jerk = np.gradient(actual_lat_accel, t_accel)
dt_avg = np.mean(np.diff(t_accel))
jerk_filter = FirstOrderFilter(0.0, 1 / (2 * np.pi * LP_FILTER_CUTOFF_HZ), dt_avg)
filtered_jerk = [jerk_filter.update(j) for j in raw_jerk]
ax[2].grid(linewidth=4)
ax[2].plot(t_accel, filtered_jerk, label='actual jerk', linewidth=6)
if CP.steerControlType == car.CarParams.SteerControlType.torque:
desired_jerk = [cs.lateralControlState.torqueState.desiredLateralJerk for cs in controlsState]
ax[2].plot(t_controlsState[:len(controlsState)], desired_jerk, label='desired jerk', linewidth=6)
ax[2].set_ylabel('Jerk (m/s^3)')
ax[2].legend()
ax[3].grid(linewidth=4)
ax[3].plot(t_carControl, [math.degrees(m.orientationNED[0]) for m in carControl], label='roll', linewidth=6)
ax[3].set_ylabel('Roll (deg)')
ax[3].legend()
ax[-1].set_xlabel("Time (s)")
fig.tight_layout()
buffer = io.BytesIO()
fig.savefig(buffer, format='webp')
plt.close(fig)
buffer.seek(0)
builder.append(f"<img src='data:image/webp;base64,{base64.b64encode(buffer.getvalue()).decode()}' style='width:100%; max-width:800px;'>\n")
builder.append("</details>\n")
summary = ["<h2>Summary</h2>\n"]
cols = ['maneuver', 'crossed', 'mean', 'min', 'max']
table = []
for description, times in target_cross_times.items():
l = [description, len(times)]
if len(times):
l.extend([round(sum(times) / len(times), 2), round(min(times), 2), round(max(times), 2)])
table.append(l)
summary.append(tabulate(table, headers=cols, tablefmt='html', numalign='left') + '\n')
sum_idx = builder.index('{ summary }')
builder[sum_idx:sum_idx + 1] = summary
with open(output_fn, "w") as f:
f.write(''.join(builder))
print(f"\nOpening report: {output_fn}\n")
webbrowser.open_new_tab(str(output_fn))
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Generate lateral maneuver report from route')
parser.add_argument('route', type=str, help='Route name (e.g. 00000000--5f742174be)')
parser.add_argument('description', type=str, nargs='?')
args = parser.parse_args()
if '/' in args.route or '|' in args.route:
lr = LogReader(args.route, only_union_types=True)
else:
segs = [seg for seg in os.listdir(Paths.log_root()) if args.route in seg]
lr = LogReader([os.path.join(Paths.log_root(), seg, 'rlog.zst') for seg in segs], only_union_types=True)
CP = lr.first('carParams')
ID = lr.first('initData')
platform = CP.carFingerprint
print('processing report for', platform)
maneuvers: list[tuple[str, list[list]]] = []
active_prev = False
description_prev = None
for msg in lr:
if msg.which() == 'alertDebug':
active = 'Active' in msg.alertDebug.alertText1 or msg.alertDebug.alertText1 == 'Complete'
if active and not active_prev:
if msg.alertDebug.alertText2 == description_prev:
maneuvers[-1][1].append([])
else:
maneuvers.append((msg.alertDebug.alertText2, [[]]))
description_prev = maneuvers[-1][0]
active_prev = active
if active_prev:
maneuvers[-1][1][-1].append(msg)
report(platform, args.route, args.description, CP, ID, maneuvers)
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
import numpy as np
from dataclasses import dataclass
from cereal import messaging, car
from openpilot.common.constants import CV
from openpilot.common.realtime import DT_MDL
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.controls.lib.drive_helpers import MIN_SPEED
from openpilot.tools.longitudinal_maneuvers.maneuversd import Action, Maneuver as _Maneuver
# thresholds for starting maneuvers
MAX_SPEED_DEV = 0.7 # deviation in m/s
MAX_CURV = 0.002 # 500 m radius
MAX_ROLL = 0.08 # 4.56°
TIMER = 2.0 # sec stable conditions before starting maneuver
@dataclass
class Maneuver(_Maneuver):
_baseline_curvature: float = 0.0
def get_accel(self, v_ego: float, lat_active: bool, curvature: float, roll: float) -> float:
self._run_completed = False
# only start maneuver on straight, flat roads
ready = abs(v_ego - self.initial_speed) < MAX_SPEED_DEV and lat_active and abs(curvature) < MAX_CURV and abs(roll) < MAX_ROLL
self._ready_cnt = (self._ready_cnt + 1) if ready else max(self._ready_cnt - 1, 0)
if self._ready_cnt > (TIMER / DT_MDL):
if not self._active:
self._baseline_curvature = curvature
self._active = True
if not self._active:
return 0.0
return self._step()
def reset(self):
super().reset()
self._ready_cnt = 0
def _sine_action(amplitude, period, duration):
t = np.linspace(0, duration, int(duration / DT_MDL) + 1)
a = amplitude * np.sin(2 * np.pi * t / period)
return Action(a.tolist(), t.tolist())
MANEUVERS = [
Maneuver(
"step right 20mph",
[Action([0.5], [1.0]), Action([-0.5], [1.5])],
repeat=2,
initial_speed=20. * CV.MPH_TO_MS,
),
Maneuver(
"step left 20mph",
[Action([-0.5], [1.0]), Action([0.5], [1.5])],
repeat=2,
initial_speed=20. * CV.MPH_TO_MS,
),
Maneuver(
"sine 0.5Hz 20mph",
[_sine_action(1.0, 2.0, 2.0), Action([0.0], [0.5])],
repeat=2,
initial_speed=20. * CV.MPH_TO_MS,
),
Maneuver(
"step right 30mph",
[Action([0.5], [1.0]), Action([-0.5], [1.5])],
repeat=2,
initial_speed=30. * CV.MPH_TO_MS,
),
Maneuver(
"step left 30mph",
[Action([-0.5], [1.0]), Action([0.5], [1.5])],
repeat=2,
initial_speed=30. * CV.MPH_TO_MS,
),
Maneuver(
"sine 0.5Hz 30mph",
[_sine_action(1.0, 2.0, 2.0), Action([0.0], [0.5])],
repeat=2,
initial_speed=30. * CV.MPH_TO_MS,
),
]
def main():
params = Params()
cloudlog.info("lateral_maneuversd is waiting for CarParams")
messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams)
sm = messaging.SubMaster(['carState', 'carControl', 'controlsState', 'selfdriveState', 'modelV2'], poll='modelV2')
pm = messaging.PubMaster(['lateralManeuverPlan', 'alertDebug'])
maneuvers = iter(MANEUVERS)
maneuver = None
complete_cnt = 0
display_holdoff = 0
prev_text = ''
while True:
sm.update()
if maneuver is None:
maneuver = next(maneuvers, None)
alert_msg = messaging.new_message('alertDebug')
alert_msg.valid = True
plan_send = messaging.new_message('lateralManeuverPlan')
accel = 0
v_ego = max(sm['carState'].vEgo, 0)
curvature = sm['controlsState'].desiredCurvature
if complete_cnt > 0:
complete_cnt -= 1
alert_msg.alertDebug.alertText1 = 'Completed'
alert_msg.alertDebug.alertText2 = maneuver.description
elif maneuver is not None:
# reset maneuver on steering override or out of range speed
if sm['carState'].steeringPressed or (maneuver.active and abs(v_ego - maneuver.initial_speed) > MAX_SPEED_DEV):
maneuver.reset()
roll = sm['carControl'].orientationNED[0] if len(sm['carControl'].orientationNED) == 3 else 0.0
accel = maneuver.get_accel(v_ego, sm['carControl'].latActive, curvature, roll)
if maneuver._run_completed:
complete_cnt = int(1.0 / DT_MDL)
alert_msg.alertDebug.alertText1 = 'Complete'
alert_msg.alertDebug.alertText2 = maneuver.description
elif maneuver.active:
action_remaining = maneuver.actions[maneuver._action_index].time_bp[-1] - maneuver._action_frames * DT_MDL
if maneuver.description.startswith('sine'):
freq = maneuver.description.split()[1]
alert_msg.alertDebug.alertText1 = f'Active sine {freq} {max(action_remaining, 0):.1f}s'
else:
alert_msg.alertDebug.alertText1 = f'Active {accel:+.1f}m/s² {max(action_remaining, 0):.1f}s'
alert_msg.alertDebug.alertText2 = maneuver.description
elif not (abs(v_ego - maneuver.initial_speed) < MAX_SPEED_DEV and sm['carControl'].latActive):
alert_msg.alertDebug.alertText1 = f'Set speed to {maneuver.initial_speed * CV.MS_TO_MPH:0.0f} mph'
elif maneuver._ready_cnt > 0:
ready_time = max(TIMER - maneuver._ready_cnt * DT_MDL, 0)
alert_msg.alertDebug.alertText1 = f'Starting: {int(ready_time) + 1}'
alert_msg.alertDebug.alertText2 = maneuver.description
else:
curv_ok = abs(curvature) < MAX_CURV
reason = 'road not straight' if not curv_ok else 'road not flat'
alert_msg.alertDebug.alertText1 = f'Waiting: {reason}'
alert_msg.alertDebug.alertText2 = maneuver.description
else:
alert_msg.alertDebug.alertText1 = 'Maneuvers Finished'
# prevent flickering text
setup = ('Set speed', 'Starting', 'Waiting')
text = alert_msg.alertDebug.alertText1
same = text == prev_text or (text.startswith('Starting') and prev_text.startswith('Starting'))
if not same and text.startswith(setup) and prev_text.startswith(setup) and display_holdoff > 0:
alert_msg.alertDebug.alertText1 = prev_text
display_holdoff -= 1
else:
prev_text = text
display_holdoff = int(0.5 / DT_MDL) if text.startswith(setup) else 0
pm.send('alertDebug', alert_msg)
plan_send.valid = maneuver is not None and maneuver.active and complete_cnt == 0
if plan_send.valid:
plan_send.lateralManeuverPlan.desiredCurvature = maneuver._baseline_curvature + accel / max(v_ego, MIN_SPEED) ** 2
pm.send('lateralManeuverPlan', plan_send)
if maneuver is not None and maneuver.finished and complete_cnt == 0:
maneuver = None
if __name__ == "__main__":
main()
+25 -15
View File
@@ -27,23 +27,14 @@ class Maneuver:
_active: bool = False
_finished: bool = False
_run_completed: bool = False
_action_index: int = 0
_action_frames: int = 0
_ready_cnt: int = 0
_repeated: int = 0
def get_accel(self, v_ego: float, long_active: bool, standstill: bool, cruise_standstill: bool) -> float:
ready = abs(v_ego - self.initial_speed) < 0.3 and long_active and not cruise_standstill
if self.initial_speed < 0.01:
ready = ready and standstill
self._ready_cnt = (self._ready_cnt + 1) if ready else 0
if self._ready_cnt > (3. / DT_MDL):
self._active = True
if not self._active:
return min(max(self.initial_speed - v_ego, -2.), 2.)
def _step(self) -> float:
self._run_completed = False
action = self.actions[self._action_index]
action_accel = np.interp(self._action_frames * DT_MDL, action.time_bp, action.accel_bp)
@@ -58,15 +49,34 @@ class Maneuver:
# repeat maneuver
elif self._repeated < self.repeat:
self._repeated += 1
self._action_index = 0
self._action_frames = 0
self._active = False
self._run_completed = True
self.reset()
# finish maneuver
else:
self._run_completed = True
self._finished = True
return float(action_accel)
def get_accel(self, v_ego: float, long_active: bool, standstill: bool, cruise_standstill: bool) -> float:
ready = abs(v_ego - self.initial_speed) < 0.3 and long_active and not cruise_standstill
if self.initial_speed < 0.01:
ready = ready and standstill
self._ready_cnt = (self._ready_cnt + 1) if ready else 0
if self._ready_cnt > (3. / DT_MDL):
self._active = True
if not self._active:
return min(max(self.initial_speed - v_ego, -2.), 2.)
return self._step()
def reset(self):
self._active = False
self._action_frames = 0
self._action_index = 0
@property
def finished(self):
return self._finished
@@ -16,7 +16,7 @@
<plot style="Lines" flip_x="false" mode="TimeSeries" flip_y="false">
<range top="27.087398" bottom="-0.905168" left="0.018309" right="59.674401"/>
<limitY/>
<curve color="#9467bd" name="/controlsState/cumLagMs"/>
<curve color="#9467bd" name="/carState/cumLagMs"/>
</plot>
</DockArea>
<DockArea name="...">
@@ -58,4 +58,3 @@
<snippets/>
<!-- - - - - - - - - - - - - - - -->
</root>
+4 -5
View File
@@ -24,8 +24,8 @@
<plot mode="TimeSeries" style="Lines" flip_y="false" flip_x="false">
<range bottom="-0.100000" right="196.811937" left="76.646983" top="0.100000"/>
<limitY/>
<curve name="/liveLocationKalman/positionGeodetic/std/0" color="#d62728"/>
<curve name="/liveLocationKalman/positionGeodetic/std/1" color="#1ac938"/>
<curve name="/liveLocationKalmanDEPRECATED/positionGeodetic/std/0" color="#d62728"/>
<curve name="/liveLocationKalmanDEPRECATED/positionGeodetic/std/1" color="#1ac938"/>
</plot>
</DockArea>
<DockArea name="...">
@@ -72,12 +72,11 @@ return distance</function>
<linked_source>/gpsLocationExternal/latitude</linked_source>
<additional_sources>
<v1>/gpsLocationExternal/longitude</v1>
<v2>/liveLocationKalman/positionGeodetic/value/0</v2>
<v3>/liveLocationKalman/positionGeodetic/value/1</v3>
<v2>/liveLocationKalmanDEPRECATED/positionGeodetic/value/0</v2>
<v3>/liveLocationKalmanDEPRECATED/positionGeodetic/value/1</v3>
</additional_sources>
</snippet>
</customMathEquations>
<snippets/>
<!-- - - - - - - - - - - - - - - -->
</root>
@@ -45,7 +45,7 @@
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
<range top="-3.593455" right="59.992103" left="0.000000" bottom="-12.190956"/>
<limitY/>
<curve name="/controlsState/cumLagMs" color="#9467bd"/>
<curve name="/carState/cumLagMs" color="#9467bd"/>
</plot>
</DockArea>
</DockSplitter>
@@ -64,4 +64,3 @@
<!-- - - - - - - - - - - - - - - -->
<!-- - - - - - - - - - - - - - - -->
</root>
+18 -9
View File
@@ -24,14 +24,14 @@
<plot style="Lines" mode="TimeSeries">
<range left="0.000000" top="0.166067" bottom="-1.598381" right="631.038276"/>
<limitY/>
<curve color="#ffb000" name="/liveLocationKalman/calibratedOrientationNED/value/0"/>
<curve color="#ffb000" name="/carControl/orientationNED/0"/>
</plot>
</DockArea>
<DockArea name="Engaged [green] Steering Pressed [blue]">
<plot style="Lines" mode="TimeSeries">
<range left="1.252984" top="1.025000" bottom="-0.025000" right="631.055584"/>
<limitY/>
<curve color="#009e73" name="/controlsState/enabled"/>
<curve color="#009e73" name="/selfdriveState/enabled"/>
<curve color="#0072b2" name="/carState/steeringPressed"/>
</plot>
</DockArea>
@@ -39,7 +39,7 @@
<plot style="Lines" mode="TimeSeries">
<range left="1.253354" top="1.025000" bottom="-0.025000" right="631.055584"/>
<limitY/>
<curve name="/carState/steeringRateLimited" color="#ffb000"/>
<curve name="steering rate limited" color="#ffb000"/>
<curve name="/controlsState/lateralControlState/pidState/saturated" color="#dc267f"/>
</plot>
</DockArea>
@@ -71,7 +71,7 @@
<plot style="Lines" mode="TimeSeries">
<range left="0.000000" top="0.158854" bottom="-0.594843" right="631.038276"/>
<limitY/>
<curve color="#ffb000" name="/liveLocationKalman/calibratedOrientationNED/value/1"/>
<curve color="#ffb000" name="/carControl/orientationNED/1"/>
</plot>
</DockArea>
<DockArea name="Engaged [green] Gas [orange] Brake [magenta]">
@@ -126,7 +126,7 @@
<plot style="Lines" mode="TimeSeries">
<range left="1.253354" top="1.025000" bottom="-0.025000" right="631.055584"/>
<limitY/>
<curve name="/carState/steeringRateLimited" color="#ffb000"/>
<curve name="steering rate limited" color="#ffb000"/>
<curve name="/controlsState/lateralControlState/pidState/saturated" color="#dc267f"/>
</plot>
</DockArea>
@@ -161,11 +161,11 @@ if (time > last_bad_time + engage_delay) then
else
return 0
end</function>
<linked_source>/liveLocationKalman/angularVelocityCalibrated/value/2</linked_source>
<linked_source>/carControl/angularVelocity/2</linked_source>
<additional_sources>
<v1>/carState/steeringPressed</v1>
<v2>/carControl/enabled</v2>
<v3>/liveLocationKalman/velocityCalibrated/value/0</v3>
<v3>/carState/vEgo</v3>
</additional_sources>
</snippet>
<snippet name="engaged curvature vehicle model">
@@ -206,7 +206,7 @@ if (time > last_bad_time + engage_delay) then
else
return 0
end</function>
<linked_source>/lateralPlan/curvatures/0</linked_source>
<linked_source>/modelV2/action/desiredCurvature</linked_source>
<additional_sources>
<v1>/carState/steeringPressed</v1>
<v2>/carControl/enabled</v2>
@@ -284,8 +284,17 @@ end</function>
<v3>/carControl/enabled</v3>
</additional_sources>
</snippet>
<snippet name="steering rate limited">
<global></global>
<function>return (math.abs(value - v1) > 0.001 or math.abs(v2 - v3) > 0.05) and 1 or 0</function>
<linked_source>/carControl/actuators/torque</linked_source>
<additional_sources>
<v1>/carOutput/actuatorsOutput/torque</v1>
<v2>/carControl/actuators/steeringAngleDeg</v2>
<v3>/carOutput/actuatorsOutput/steeringAngleDeg</v3>
</additional_sources>
</snippet>
</customMathEquations>
<snippets/>
<!-- - - - - - - - - - - - - - - -->
</root>
+3 -1
View File
@@ -3,7 +3,7 @@ Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal')
replay_env = env.Clone()
replay_env['CCFLAGS'] += ['-Wno-deprecated-declarations']
base_frameworks = []
base_frameworks = ['VideoToolbox', 'CoreMedia', 'CoreFoundation', 'CoreVideo'] if arch == "Darwin" else []
base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread']
replay_lib_src = ["replay.cc", "consoleui.cc", "camera.cc", "filereader.cc", "logreader.cc", "framereader.cc",
@@ -13,6 +13,8 @@ if arch != "Darwin":
replay_lib = replay_env.Library("replay", replay_lib_src, LIBS=base_libs, FRAMEWORKS=base_frameworks)
Export('replay_lib')
replay_libs = [replay_lib, 'avformat', 'avcodec', 'swresample', 'avutil', 'x264', 'z', 'bz2', 'zstd', 'yuv', 'ncurses'] + base_libs
if arch != "Darwin":
replay_libs += ['va', 'va-drm', 'drm']
replay_env.Program("replay", ["main.cc"], LIBS=replay_libs, FRAMEWORKS=base_frameworks)
if GetOption('extras'):
+55 -3
View File
@@ -1,31 +1,68 @@
#include "tools/replay/logreader.h"
#include <algorithm>
#include <chrono>
#include <utility>
#include "tools/replay/filereader.h"
#include "tools/replay/py_downloader.h"
#include "tools/replay/util.h"
#include "common/util.h"
bool LogReader::load(const std::string &url, std::atomic<bool> *abort, bool local_cache) {
bool LogReader::load(const std::string &url, std::atomic<bool> *abort, bool local_cache,
const ProgressCallback &progress) {
using Clock = std::chrono::steady_clock;
compressed_size_ = 0;
decompressed_size_ = 0;
download_seconds_ = 0.0;
decompress_seconds_ = 0.0;
parse_seconds_ = 0.0;
if (progress) {
installDownloadProgressHandler([progress](uint64_t cur, uint64_t total, bool success) {
if (success) {
progress(ProgressStage::Downloading, cur, total);
}
});
}
const auto download_start = Clock::now();
std::string data = FileReader(local_cache).read(url, abort);
const auto download_end = Clock::now();
if (progress) {
installDownloadProgressHandler(nullptr);
}
compressed_size_ = data.size();
download_seconds_ = std::chrono::duration<double>(download_end - download_start).count();
if (!data.empty()) {
const auto decompress_start = Clock::now();
if (url.find(".bz2") != std::string::npos || util::starts_with(data, "BZh9")) {
data = decompressBZ2(data, abort);
} else if (url.find(".zst") != std::string::npos || util::starts_with(data, "\x28\xB5\x2F\xFD")) {
data = decompressZST(data, abort);
}
const auto decompress_end = Clock::now();
decompress_seconds_ = std::chrono::duration<double>(decompress_end - decompress_start).count();
}
decompressed_size_ = data.size();
bool success = !data.empty() && load(data.data(), data.size(), abort);
bool success = !data.empty() && load(data.data(), data.size(), abort, progress);
if (filters_.empty())
raw_ = std::move(data);
return success;
}
bool LogReader::load(const char *data, size_t size, std::atomic<bool> *abort) {
bool LogReader::load(const char *data, size_t size, std::atomic<bool> *abort,
const ProgressCallback &progress) {
using Clock = std::chrono::steady_clock;
const auto parse_start = Clock::now();
try {
events.reserve(65000);
kj::ArrayPtr<const capnp::word> words((const capnp::word *)data, size / sizeof(capnp::word));
const uint64_t total_bytes = size;
const uint64_t report_step = std::max<uint64_t>(1, total_bytes / 200);
uint64_t last_reported = 0;
if (progress) {
progress(ProgressStage::Parsing, 0, total_bytes);
}
while (words.size() > 0 && !(abort && *abort)) {
capnp::FlatArrayMessageReader reader(words);
auto event = reader.getRoot<cereal::Event>();
@@ -56,15 +93,30 @@ bool LogReader::load(const char *data, size_t size, std::atomic<bool> *abort) {
events.emplace_back(which, sof ? sof : mono_time, event_data, idx.getSegmentNum());
}
}
if (progress) {
const uint64_t current_bytes =
total_bytes - static_cast<uint64_t>(words.size() * sizeof(capnp::word));
if (current_bytes >= total_bytes || current_bytes - last_reported >= report_step) {
progress(ProgressStage::Parsing, current_bytes, total_bytes);
last_reported = current_bytes;
}
}
}
} catch (const kj::Exception &e) {
rWarning("Failed to parse log : %s.\nRetrieved %zu events from corrupt log", e.getDescription().cStr(), events.size());
}
if (progress) {
progress(ProgressStage::Parsing, size, size);
}
if (requires_migration) {
migrateOldEvents();
}
parse_seconds_ = std::chrono::duration<double>(Clock::now() - parse_start).count();
if (!events.empty() && !(abort && *abort)) {
events.shrink_to_fit();
std::sort(events.begin(), events.end());
+23 -2
View File
@@ -1,5 +1,7 @@
#pragma once
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
@@ -26,12 +28,26 @@ public:
class LogReader {
public:
enum class ProgressStage {
Downloading,
Parsing,
};
using ProgressCallback = std::function<void(ProgressStage stage, uint64_t current, uint64_t total)>;
LogReader(const std::vector<bool> &filters = {}) { filters_ = filters; }
bool load(const std::string &url, std::atomic<bool> *abort = nullptr,
bool local_cache = false);
bool load(const char *data, size_t size, std::atomic<bool> *abort = nullptr);
bool local_cache = false, const ProgressCallback &progress = {});
bool load(const char *data, size_t size, std::atomic<bool> *abort = nullptr,
const ProgressCallback &progress = {});
std::vector<Event> events;
uint64_t compressed_size() const { return compressed_size_; }
uint64_t decompressed_size() const { return decompressed_size_; }
double download_seconds() const { return download_seconds_; }
double decompress_seconds() const { return decompress_seconds_; }
double parse_seconds() const { return parse_seconds_; }
private:
void migrateOldEvents();
@@ -39,4 +55,9 @@ private:
bool requires_migration = true;
std::vector<bool> filters_;
MonotonicBuffer buffer_{1024 * 1024};
uint64_t compressed_size_ = 0;
uint64_t decompressed_size_ = 0;
double download_seconds_ = 0.0;
double decompress_seconds_ = 0.0;
double parse_seconds_ = 0.0;
};
+7 -2
View File
@@ -149,11 +149,16 @@ std::string runPython(const std::vector<std::string> &args, std::atomic<bool> *a
int status;
waitpid(pid, &status, 0);
bool failed = (abort && *abort) ||
const bool aborted = abort && *abort;
const bool expected_sigterm = aborted && WIFSIGNALED(status) && WTERMSIG(status) == SIGTERM;
bool failed = aborted ||
(WIFEXITED(status) && WEXITSTATUS(status) != 0) ||
WIFSIGNALED(status);
if (failed) {
if (WIFEXITED(status) && WEXITSTATUS(status) != 0) {
if (expected_sigterm) {
// Route/camera teardown cancels outstanding downloader subprocesses.
// Keep that expected shutdown path quiet.
} else if (WIFEXITED(status) && WEXITSTATUS(status) != 0) {
rWarning("py_downloader: process exited with code %d", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
rWarning("py_downloader: process killed by signal %d", WTERMSIG(status));