Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d82050b
chore: remove stale editor backup files from src/ui
SomethingNew71 Jul 16, 2026
2a94605
chore(deps): refresh Cargo.lock with semver-compatible updates
SomethingNew71 Jul 16, 2026
14ff56e
perf(build): enable codegen-units=1 for release builds
SomethingNew71 Jul 16, 2026
0d53657
perf(app): event-driven IPC repaint via callback instead of 10Hz poll…
SomethingNew71 Jul 16, 2026
5c37458
fix(speeduino): bounds-check v2 MLG header before reads — truncated v…
SomethingNew71 Jul 16, 2026
caa67e5
fix(app): reindex analysis_results when a file is removed — cached re…
SomethingNew71 Jul 16, 2026
16fbe0f
fix(normalize): remove ambiguous Speed alias from RPM — it collided w…
SomethingNew71 Jul 16, 2026
aad3c1c
fix(haltech): preserve column alignment when a field fails to parse —…
SomethingNew71 Jul 16, 2026
bb7a118
feat(settings): persist unit preferences, font scale, colorblind mode…
SomethingNew71 Jul 16, 2026
6865c1e
perf(chart): cache downsampled chart points per channel keyed on the …
SomethingNew71 Jul 16, 2026
3d7be8a
fix(app): queue file loads instead of dropping in-flight results — op…
SomethingNew71 Jul 16, 2026
f186ecf
fix(adapters): sanitize API-supplied vendor/id into a filename-safe s…
SomethingNew71 Jul 16, 2026
2e227c4
fix(haltech): handle midnight rollover in wall-clock timestamps — ses…
SomethingNew71 Jul 16, 2026
6b63439
perf(scatter): cache the heatmap histogram per axis selection — previ…
SomethingNew71 Jul 16, 2026
4d1587c
test: add regression coverage for haltech column alignment + midnight…
SomethingNew71 Jul 16, 2026
e4aae9c
chore(release): bump version to 2.11.0
SomethingNew71 Jul 16, 2026
ca55443
docs: bring CLAUDE.md up to date with the current codebase — analysis…
SomethingNew71 Jul 16, 2026
05e0c99
fix: address PR #75 review feedback
SomethingNew71 Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 181 additions & 40 deletions CLAUDE.md

Large diffs are not rendered by default.

1,548 changes: 740 additions & 808 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ultralog"
version = "2.10.1"
version = "2.11.0"
edition = "2021"
description = "A high-performance ECU log viewer written in Rust"
authors = ["Cole Gentry"]
Expand Down Expand Up @@ -107,6 +107,7 @@ objc2-foundation = "0.3.2"
opt-level = 3
lto = true
strip = true
codegen-units = 1

# Faster compile times for debug builds
[profile.dev]
Expand Down
24 changes: 22 additions & 2 deletions src/adapters/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,26 @@ pub fn load_cached_adapters() -> Option<Vec<AdapterSpec>> {
}
}

/// Build a safe cache filename from API-supplied vendor/id fields.
/// Restricts to a filename-safe slug so a hostile or corrupted API response
/// can't escape the cache directory (e.g. `../`) via `Path::join`.
fn spec_cache_filename(vendor: &str, id: &str) -> String {
let slug = |s: &str| -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect::<String>()
.trim_matches('.')
.to_string()
};
format!("{}-{}.json", slug(vendor), slug(id))
}

/// Save adapters to cache
pub fn save_adapters_to_cache(adapters: &[AdapterSpec]) -> Result<(), CacheError> {
let cache_dir = ensure_cache_dirs()?;
Expand All @@ -217,7 +237,7 @@ pub fn save_adapters_to_cache(adapters: &[AdapterSpec]) -> Result<(), CacheError

// Save each adapter
for adapter in adapters {
let filename = format!("{}-{}.json", adapter.vendor, adapter.id);
let filename = spec_cache_filename(&adapter.vendor, &adapter.id);
let path = adapters_dir.join(&filename);

let content = serde_json::to_string_pretty(adapter)
Expand Down Expand Up @@ -289,7 +309,7 @@ pub fn save_protocols_to_cache(protocols: &[ProtocolSpec]) -> Result<(), CacheEr

// Save each protocol
for protocol in protocols {
let filename = format!("{}-{}.json", protocol.vendor, protocol.id);
let filename = spec_cache_filename(&protocol.vendor, &protocol.id);
let path = protocols_dir.join(&filename);

let content = serde_json::to_string_pretty(protocol)
Expand Down
234 changes: 174 additions & 60 deletions src/app.rs

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/ipc/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,10 @@ impl UltraLogApp {
.position(|c| c.name().eq_ignore_ascii_case(name))
{
computed.remove(pos);
// Later computed channels shift down one index, so
// cached downsampled points and min/max no longer line up.
self.downsample_cache.clear();
self.minmax_cache.clear();
}
}
}
Expand Down
32 changes: 29 additions & 3 deletions src/ipc/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Arc;
use std::thread;

use super::commands::{IpcCommand, IpcResponse};
use super::DEFAULT_IPC_PORT;

/// Callback invoked when a command arrives, so the GUI can wake up
/// (request a repaint) instead of polling on a timer.
pub type RepaintCallback = Arc<dyn Fn() + Send + Sync>;

/// IPC Server that listens for commands from the MCP server
pub struct IpcServer {
/// Receiver for incoming commands (polled by the GUI)
Expand All @@ -27,16 +32,26 @@ impl IpcServer {
Self::start_on_port(DEFAULT_IPC_PORT)
}

/// Start a new IPC server on the default port with a repaint callback
/// invoked whenever a command arrives (event-driven GUI wake-up).
pub fn start_with_repaint(repaint: RepaintCallback) -> Result<Self, String> {
Self::start_inner(DEFAULT_IPC_PORT, Some(repaint))
}

/// Start a new IPC server on a specific port
pub fn start_on_port(port: u16) -> Result<Self, String> {
Self::start_inner(port, None)
}

fn start_inner(port: u16, repaint: Option<RepaintCallback>) -> Result<Self, String> {
let listener = TcpListener::bind(format!("127.0.0.1:{}", port))
.map_err(|e| format!("Failed to bind to port {}: {}", port, e))?;

let (command_tx, command_rx) = mpsc::channel();

// Spawn the listener thread
thread::spawn(move || {
Self::listener_loop(listener, command_tx);
Self::listener_loop(listener, command_tx, repaint);
});

tracing::info!("IPC server started on port {}", port);
Expand Down Expand Up @@ -64,14 +79,19 @@ impl IpcServer {
}

/// Main listener loop (runs in background thread)
fn listener_loop(listener: TcpListener, command_tx: Sender<(IpcCommand, Sender<IpcResponse>)>) {
fn listener_loop(
listener: TcpListener,
command_tx: Sender<(IpcCommand, Sender<IpcResponse>)>,
repaint: Option<RepaintCallback>,
) {
loop {
match listener.accept() {
Ok((stream, addr)) => {
tracing::info!("MCP client connected from {}", addr);
let tx = command_tx.clone();
let repaint = repaint.clone();
thread::spawn(move || {
Self::handle_connection(stream, tx);
Self::handle_connection(stream, tx, repaint);
});
}
Err(e) => {
Expand All @@ -86,6 +106,7 @@ impl IpcServer {
fn handle_connection(
mut stream: TcpStream,
command_tx: Sender<(IpcCommand, Sender<IpcResponse>)>,
repaint: Option<RepaintCallback>,
) {
let peer_addr = stream.peer_addr().ok();

Expand Down Expand Up @@ -130,6 +151,11 @@ impl IpcServer {
break;
}

// Wake the GUI so it processes the command immediately
if let Some(repaint) = &repaint {
repaint();
}

// Wait for the response from the GUI
let response = match response_rx.recv_timeout(std::time::Duration::from_secs(30)) {
Ok(resp) => resp,
Expand Down
1 change: 0 additions & 1 deletion src/normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,6 @@ static NORMALIZATION_MAP: LazyLock<HashMap<&'static str, Vec<&'static str>>> =
vec![
"RPM",
"rpm",
"Speed",
"PCS RPM4",
"Engine RPM4",
"RPM_INC_RPM",
Expand Down
42 changes: 36 additions & 6 deletions src/parsers/haltech.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,10 @@ impl Parseable for Haltech {
}

// Phase 2: Parse data rows in parallel
// Each row is parsed independently, returning (timestamp, values)
let parsed_rows: Vec<(f64, Vec<Value>)> = data_lines
// Each row is parsed independently, returning (timestamp, values).
// Unparseable fields (blank, "N/A", text gears) become None so the
// row keeps positional alignment with the channel list.
let parsed_rows: Vec<(f64, Vec<Option<Value>>)> = data_lines
.par_iter()
.filter_map(|line| {
let parts: Vec<&str> = line.split(',').collect();
Expand All @@ -380,10 +382,10 @@ impl Parseable for Haltech {
let timestamp_secs = Self::parse_timestamp(timestamp_str)?;

// Parse remaining values and apply unit conversions
let values: Vec<Value> = parts[1..]
let values: Vec<Option<Value>> = parts[1..]
.iter()
.enumerate()
.filter_map(|(idx, v)| {
.map(|(idx, v)| {
let v = v.trim();
let raw_value: f64 = v.parse().ok()?;

Expand All @@ -405,18 +407,46 @@ impl Parseable for Haltech {
})
.collect();

// Phase 3: Post-process results (sequential for ordering)
// Phase 3: Post-process results (sequential for ordering).
// Fill unparseable fields with the last known value for that column
// (0.0 before the first valid sample), matching the ECUMaster parser.
let data_count = parsed_rows.len();
let mut times: Vec<f64> = Vec::with_capacity(data_count);
let mut data: Vec<Vec<Value>> = Vec::with_capacity(data_count);

if !parsed_rows.is_empty() {
// First timestamp is the base for relative times
let first_timestamp = parsed_rows[0].0;
let mut last_values: Vec<Value> = vec![Value::Float(0.0); channels.len()];
// Timestamps are wall-clock time-of-day, so a session crossing
// midnight wraps from ~86400 back to 0. Accumulate a 24h offset
// on each backwards jump (>1s guard skips timestamp jitter) to
// keep `times` monotonic — computed-channel time-shift lookups
// binary-search this vector and require sorted order.
let mut prev_raw = f64::NEG_INFINITY;
let mut day_offset = 0.0_f64;

for (timestamp, values) in parsed_rows {
if timestamp + 1.0 < prev_raw {
day_offset += 86_400.0;
}
prev_raw = timestamp;
let timestamp = timestamp + day_offset;
let row: Vec<Value> = values
.into_iter()
.enumerate()
.map(|(idx, value)| match value {
Some(v) => {
if let Some(slot) = last_values.get_mut(idx) {
*slot = v;
}
v
}
None => last_values.get(idx).copied().unwrap_or(Value::Float(0.0)),
})
.collect();
times.push(timestamp - first_timestamp);
data.push(values);
data.push(row);
}
}

Expand Down
Loading
Loading