jotpluggler: part one (#37730)

This commit is contained in:
Adeeb Shihadeh
2026-03-25 19:49:38 -07:00
committed by GitHub
parent e4813645fa
commit b706673e1c
51 changed files with 14061 additions and 8 deletions

View File

@@ -49,7 +49,7 @@ pkgs = [importlib.import_module(name) for name in pkg_names]
allowed_system_libs = {
"EGL", "GLESv2", "GL",
"Qt5Charts", "Qt5Core", "Qt5Gui", "Qt5Widgets",
"dl", "drm", "gbm", "m", "pthread",
"dl", "drm", "gbm", "m", "pthread",
}
def _resolve_lib(env, name):
@@ -259,6 +259,7 @@ if arch != "larch64":
SConscript([
'tools/replay/SConscript',
'tools/cabana/SConscript',
'tools/jotpluggler/SConscript',
])

9
tools/jotpluggler/.gitignore vendored Normal file
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/

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)

1914
tools/jotpluggler/app.cc Normal file

File diff suppressed because it is too large Load Diff

884
tools/jotpluggler/app.h Normal file
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_;
};

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();
}
}

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;
}
}

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
tools/jotpluggler/common.cc Normal file
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;
}
}

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);

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
tools/jotpluggler/dbc.h Normal file
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

View File

@@ -0,0 +1,2 @@
*
!.gitignore

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);
}

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
tools/jotpluggler/layout.cc Normal file
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);
}

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
tools/jotpluggler/layouts/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.jotpluggler_autosave/

View File

@@ -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}]}]}}]}

View File

@@ -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}}]}}]}

View File

@@ -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"}}]}

View File

@@ -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"}]}]}]}}]}

View File

@@ -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"}]}]}}]}

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"}]}]}}]}

View File

@@ -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"}]}]}]}}]}

View File

@@ -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"}]}]}}]}

View File

@@ -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"}]}]}}]}

View File

@@ -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}]}]}}]}

View File

@@ -0,0 +1 @@
{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"curves": [], "title": "..."}}]}

View File

@@ -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"}]}]}}]}

View File

@@ -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

View File

@@ -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
tools/jotpluggler/logs.cc Normal file
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
tools/jotpluggler/main.cc Normal file
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);
}

1328
tools/jotpluggler/map.cc Normal file

File diff suppressed because it is too large Load Diff

61
tools/jotpluggler/map.h Normal file
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
tools/jotpluggler/math_eval.py Executable file
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

1027
tools/jotpluggler/plot.cc Normal file

File diff suppressed because it is too large Load Diff

173
tools/jotpluggler/render.cc Normal file
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;
}

1280
tools/jotpluggler/runtime.cc Normal file

File diff suppressed because it is too large Load Diff

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;
}

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
tools/jotpluggler/stream.cc Normal file
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
tools/jotpluggler/util.cc Normal file
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
tools/jotpluggler/util.h Normal file
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);

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());

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;
};

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));