16 次代码提交 5f47d3146d ... de9e18c1b6

作者 SHA1 备注 提交日期
  k4be de9e18c1b6 gpx: post-gap settling, lon error scaling, fix spike check and avg timestamp 1 天之前
  k4be 241b5fb20a autopause: smooth speed for resume, add quarter-window distance check 1 天之前
  k4be f9adcc3212 gps-test-tool: add GUI with OSM map view and live settings 1 天之前
  k4be 0646c9ed35 gps-test-tool: add settings options and single-run -o mode 1 天之前
  k4be e7ae0dcab0 autopause: fix speed-rounding false resume, add heading-consistency check 3 周之前
  k4be b9d9659eaa nmea: count low-quality-fix rejections toward rejected_count 3 周之前
  k4be c2b02e5b42 fix: keypress bitfield truncated multi-key mask to 1 bit 3 周之前
  k4be 4c28546f86 ds18b20: re-arm poll timer even with no sensor present 3 周之前
  k4be 8ccccfc65e add soft/common.h to stop duplicating structs between builds 3 周之前
  k4be 1609a9ae40 autopause: manual resume didn't clear an active auto-pause 3 周之前
  k4be 5f47d3146d autopause: fix speed-rounding false resume, add heading-consistency check 3 周之前
  k4be c3378005c5 nmea: count low-quality-fix rejections toward rejected_count 3 周之前
  k4be 522095585d fix: keypress bitfield truncated multi-key mask to 1 bit 3 周之前
  k4be 05050f4e7e ds18b20: re-arm poll timer even with no sensor present 3 周之前
  k4be a8bbc3b3f4 add soft/common.h to stop duplicating structs between builds 3 周之前
  k4be b3ac3670d7 autopause: manual resume didn't clear an active auto-pause 3 周之前
共有 8 个文件被更改,包括 1501 次插入 和 72 次删除
  1. 1 0
      gps-test-tool/.gitignore
  2. 13 2
      gps-test-tool/Makefile
  3. 44 11
      gps-test-tool/README.md
  4. 1207 0
      gps-test-tool/gui.c
  5. 84 23
      gps-test-tool/main.c
  6. 61 25
      soft/autopause.c
  7. 5 2
      soft/common.h
  8. 86 9
      soft/gpx.c

+ 1 - 0
gps-test-tool/.gitignore

@@ -1,4 +1,5 @@
 gps-test-tool
+gps-test-tool-gui
 test-malformed
 *.nmea
 !malformed_test.nmea

+ 13 - 2
gps-test-tool/Makefile

@@ -8,10 +8,14 @@ TARGET = gps-test-tool
 SOURCES = main.c gpx_wrapper.c nmea_actual_wrapper.c syslog_wrapper.c autopause_wrapper.c
 OBJECTS = $(SOURCES:.c=.o)
 
+GUI = gps-test-tool-gui
+GUI_CFLAGS = -Wall -Wextra -g -O2 $(shell pkg-config --cflags gtk+-3.0 libsoup-3.0)
+GUI_LDFLAGS = $(shell pkg-config --libs gtk+-3.0 libsoup-3.0) -lm
+
 TEST_MALFORMED = test-malformed
 TEST_MALFORMED_OBJECTS = test_malformed.o gpx_wrapper.o nmea_actual_wrapper.o syslog_wrapper.o
 
-.PHONY: all clean run-test-malformed
+.PHONY: all gui clean run-test-malformed
 
 all: $(TARGET)
 
@@ -21,6 +25,11 @@ $(TARGET): $(OBJECTS)
 	@echo "Build complete! Run with:"
 	@echo "  ./$(TARGET) <input.nmea>"
 
+gui: $(TARGET) $(GUI)
+
+$(GUI): gui.c
+	$(CC) $(GUI_CFLAGS) gui.c -o $(GUI) $(GUI_LDFLAGS)
+
 $(TEST_MALFORMED): $(TEST_MALFORMED_OBJECTS)
 	$(CC) $(TEST_MALFORMED_OBJECTS) -o $(TEST_MALFORMED) $(LDFLAGS)
 
@@ -31,7 +40,7 @@ run-test-malformed: $(TEST_MALFORMED)
 	$(CC) $(CFLAGS) -c $< -o $@
 
 clean:
-	rm -f $(OBJECTS) $(TEST_MALFORMED) test_malformed.o $(TARGET) output_*.gpx debug_*.txt
+	rm -f $(OBJECTS) $(TEST_MALFORMED) test_malformed.o $(TARGET) $(GUI) output_*.gpx debug_*.txt
 
 test: $(TARGET)
 	@echo "Running test with sample NMEA data..."
@@ -50,6 +59,7 @@ help:
 	@echo ""
 	@echo "Targets:"
 	@echo "  all (default) - Build the gps-test-tool executable"
+	@echo "  gui           - Also build gps-test-tool-gui (needs GTK3, libsoup3)"
 	@echo "  clean         - Remove built files and output"
 	@echo "  test          - Build and run with sample data"
 	@echo "  help          - Show this help message"
@@ -58,3 +68,4 @@ help:
 	@echo "  make           # Build"
 	@echo "  make test      # Build and test"
 	@echo "  ./gps-test-tool <input.nmea>  # Run"
+	@echo "  ./gps-test-tool-gui [input.nmea]  # Run GUI"

+ 44 - 11
gps-test-tool/README.md

@@ -56,20 +56,30 @@ the same input.
 
 ### Adjusting settings
 
-There's no command-line or interactive settings UI. To try different
-device settings against a log, edit the defaults in `reset_system()` in
-`main.c` (around line 67) and rebuild:
+Device settings can be given as options (defaults in brackets; the ranges
+the device accepts are in `soft/settings.c`'s `limits_min_u8`/`limits_max_u8`):
 
-```c
-System.conf.skip_points = 0;
-System.conf.auto_pause_time = 30;   // seconds
-System.conf.auto_pause_dist = 10;   // meters
-System.conf.auto_pause_speed = 3;   // km/h
-System.conf.min_sats = 4;           // minimum satellites for a valid fix
 ```
+-A        disable auto-pause                 [enabled]
+-t SEC    auto-pause time                    [30]
+-d M      auto-pause distance                [10]
+-s KMH    auto-pause speed                   [3]
+-k N      points skipped after a fix         [0]
+-m N      minimum satellites                 [4]
+-v        verbose system log
+```
+
+The auto-pause defaults match `soft/settings.c`'s out-of-the-box defaults.
+
+### Single run
 
-These match `soft/settings.c`'s out-of-the-box defaults, so start from a
-known-good baseline before changing them.
+`-o FILE` makes a single run with the given settings instead of the
+filtered/unfiltered pair, writing only that GPX file. `-l FILE` also writes
+its debug output, and `-n` disables filters:
+
+```
+./gps-test-tool -o out.gpx -l out.txt -n -t 60 input.nmea
+```
 
 ### Input format
 
@@ -82,6 +92,29 @@ $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
 $GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47
 ```
 
+## GUI
+
+```
+make gui                          # builds ./gps-test-tool-gui too (needs GTK3, libsoup3)
+./gps-test-tool-gui [input.nmea]
+```
+
+Shows the input NMEA track (thin dark line) and the resulting GPX track
+(one colour per `<trkseg>`, i.e. per pause-separated segment) on an
+OpenStreetMap background. The GPX is produced by running `gps-test-tool -o`
+(looked up next to the GUI binary, then in `PATH`), so it is always the
+same result the command-line tool gives. Changing any setting in the side
+panel reprocesses the file immediately; the summary from the debug output
+is shown below the settings. "Save GPX..." stores the current result.
+
+- Mouse wheel zooms around the cursor, left-drag pans, "Zoom to fit" goes
+  back to the whole track.
+- Coordinates under the cursor are shown in the bottom bar; a left click
+  freezes them (and marks the point), a right click releases them.
+- Settings, the last file and the window size are remembered in
+  `~/.config/gps-test-tool/gui.ini`; map tiles are cached in
+  `~/.cache/gps-test-tool/tiles`.
+
 ## Automated tests
 
 A regression test (`test-malformed`) feeds the real `gps_parse()` a fixture

+ 1207 - 0
gps-test-tool/gui.c

@@ -0,0 +1,1207 @@
+/*
+ * GPS Test Tool - GUI front-end
+ *
+ * Plots an NMEA log (input) and the GPX track the embedded code produces
+ * from it (output) on an OpenStreetMap background. The GPX is generated by
+ * running the command-line gps-test-tool (-o single-run mode) with the
+ * settings chosen in the side panel; every change re-runs it.
+ *
+ * Usage: ./gps-test-tool-gui [input.nmea]
+ */
+
+#include <gtk/gtk.h>
+#include <libsoup/soup.h>
+#include <glib/gstdio.h>
+#include <glib-unix.h>
+#include <signal.h>
+#include <string.h>
+#include <math.h>
+
+#define TILE_SIZE	256
+#define MIN_ZOOM	2
+#define MAX_ZOOM	19
+#define TILE_URL	"https://tile.openstreetmap.org/%d/%d/%d.png"
+#define USER_AGENT	"GPStracker-gps-test-tool-gui/1.0"
+#define MAX_DOWNLOADS	2	/* OSM tile usage policy: at most 2 connections */
+#define MEM_CACHE_MAX	600	/* tiles kept in memory before evicting unused ones */
+#define TILE_MAX_AGE	(30 * 24 * 3600)	/* re-download disk-cached tiles older than this */
+#define FALLBACK_LEVELS	6	/* how many zoom levels up to look for a placeholder tile */
+#define CLICK_SLOP	4	/* pixels a press may move and still count as a click */
+#define DOTS_ZOOM	16	/* draw individual points from this zoom level up */
+#define REPROCESS_DELAY_MS	150
+
+/* A track point in normalized Web Mercator coordinates (0..1 both ways) */
+struct mpoint {
+	double x, y;
+};
+
+enum {
+	DL_IDLE,
+	DL_QUEUED,
+	DL_LOADING,
+	DL_FAILED,
+	DL_DONE,
+};
+
+struct tile {
+	int z, x, y;
+	cairo_surface_t *surface;
+	unsigned char dl_state;
+	unsigned char stale;
+	guint last_used;
+};
+
+struct dl_request {
+	struct tile *tile;
+	SoupMessage *msg;
+};
+
+struct setting_spin {
+	const char *key;
+	const char *label;
+	const char *cli_opt;
+	int min, max, def;
+	GtkWidget *widget;
+};
+
+/* Ranges match soft/settings.c's limits_min_u8/limits_max_u8; defaults
+ * match the command-line tool's. */
+static struct setting_spin spins[] = {
+	{ "skip_points", "Skip points", "-k", 0, 120, 0, NULL },
+	{ "min_sats", "Min. satellites", "-m", 4, 12, 4, NULL },
+	{ "auto_pause_time", "Time (s)", "-t", 10, 120, 30, NULL },
+	{ "auto_pause_dist", "Distance (m)", "-d", 2, 100, 10, NULL },
+	{ "auto_pause_speed", "Speed (km/h)", "-s", 0, 20, 3, NULL },
+};
+#define SPIN_SKIP	0
+#define SPIN_MIN_SATS	1
+#define SPIN_AP_FIRST	2
+#define SPIN_COUNT	(sizeof(spins) / sizeof(spins[0]))
+
+/* Per-segment colours for the GPX output */
+static const double seg_colors[][3] = {
+	{ 0.12, 0.47, 0.71 },
+	{ 1.00, 0.50, 0.05 },
+	{ 0.17, 0.63, 0.17 },
+	{ 0.84, 0.15, 0.16 },
+	{ 0.58, 0.40, 0.74 },
+	{ 0.09, 0.75, 0.81 },
+	{ 0.89, 0.47, 0.76 },
+	{ 0.55, 0.34, 0.29 },
+	{ 0.74, 0.74, 0.13 },
+};
+#define SEG_COLOR_COUNT	(sizeof(seg_colors) / sizeof(seg_colors[0]))
+
+static struct {
+	GtkWidget *window, *map, *coord_label, *status_label, *summary_label;
+	GtkWidget *save_button;
+	GtkWidget *chk_disable_filters, *chk_auto_pause, *chk_show_nmea, *chk_show_gpx;
+	gboolean loading_settings;
+
+	char *input_file;
+	GArray *nmea;		/* struct mpoint */
+	GPtrArray *segments;	/* GArray of struct mpoint, one per <trkseg> */
+	gsize gpx_points;
+
+	char *cli_path;
+	char *tmpdir, *out_gpx, *out_log;
+	gboolean have_result;
+	GPid child;
+	gboolean running, pending;
+	guint reprocess_id;
+
+	int zoom;
+	double cx, cy;		/* view centre in world pixels at the current zoom */
+	gboolean need_fit;
+	gboolean pressed, dragging;
+	double press_x, press_y, last_x, last_y;
+	double scroll_accum;
+	gboolean have_cursor, frozen;
+	double cursor_mx, cursor_my, frozen_mx, frozen_my;
+
+	GHashTable *tiles;	/* "z/x/y" -> struct tile */
+	GQueue dl_queue;	/* struct tile, DL_QUEUED */
+	int dl_active;
+	guint frame;
+	SoupSession *soup;
+	char *cache_dir;
+
+	GKeyFile *config;
+	char *config_path;
+} app;
+
+/* ===== Projection ===== */
+
+static double world_size(int zoom) {
+	return TILE_SIZE * (double)(1 << zoom);
+}
+
+static void latlon_to_merc(double lat, double lon, struct mpoint *p) {
+	double rlat = lat * M_PI / 180.0;
+	p->x = (lon + 180.0) / 360.0;
+	p->y = (1.0 - log(tan(rlat) + 1.0 / cos(rlat)) / M_PI) / 2.0;
+}
+
+static void merc_to_latlon(double x, double y, double *lat, double *lon) {
+	*lon = x * 360.0 - 180.0;
+	*lat = atan(sinh(M_PI * (1.0 - 2.0 * y))) * 180.0 / M_PI;
+}
+
+static void screen_to_merc(double sx, double sy, double *mx, double *my) {
+	double ws = world_size(app.zoom);
+	/* Same whole-pixel origin as map_draw() */
+	*mx = (floor(app.cx - gtk_widget_get_allocated_width(app.map) / 2.0) + sx) / ws;
+	*my = (floor(app.cy - gtk_widget_get_allocated_height(app.map) / 2.0) + sy) / ws;
+}
+
+static void clamp_view(void) {
+	double ws = world_size(app.zoom);
+	if (app.cy < 0)
+		app.cy = 0;
+	if (app.cy > ws)
+		app.cy = ws;
+	app.cx = fmod(app.cx, ws);
+	if (app.cx < 0)
+		app.cx += ws;
+}
+
+/* ===== Config ===== */
+
+static void config_save(void) {
+	char *dir = g_path_get_dirname(app.config_path);
+	g_mkdir_with_parents(dir, 0755);
+	g_free(dir);
+	g_key_file_save_to_file(app.config, app.config_path, NULL);
+}
+
+static int config_get_int(const char *group, const char *key, int def) {
+	GError *err = NULL;
+	int v = g_key_file_get_integer(app.config, group, key, &err);
+	if (err) {
+		g_error_free(err);
+		return def;
+	}
+	return v;
+}
+
+static void settings_store(void) {
+	unsigned i;
+	if (app.loading_settings)
+		return;
+	for (i = 0; i < SPIN_COUNT; i++)
+		g_key_file_set_integer(app.config, "settings", spins[i].key,
+			gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(spins[i].widget)));
+	g_key_file_set_boolean(app.config, "settings", "disable_filters",
+		gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_disable_filters)));
+	g_key_file_set_boolean(app.config, "settings", "auto_pause",
+		gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause)));
+	g_key_file_set_boolean(app.config, "view", "show_nmea",
+		gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_nmea)));
+	g_key_file_set_boolean(app.config, "view", "show_gpx",
+		gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_gpx)));
+	config_save();
+}
+
+/* ===== Tiles ===== */
+
+static char *tile_cache_path(int z, int x, int y) {
+	return g_strdup_printf("%s/%d/%d/%d.png", app.cache_dir, z, x, y);
+}
+
+static cairo_surface_t *surface_from_pixbuf(GdkPixbuf *pix) {
+	cairo_surface_t *s;
+	cairo_t *cr;
+	if (!pix)
+		return NULL;
+	s = cairo_image_surface_create(CAIRO_FORMAT_RGB24, TILE_SIZE, TILE_SIZE);
+	cr = cairo_create(s);
+	gdk_cairo_set_source_pixbuf(cr, pix, 0, 0);
+	cairo_paint(cr);
+	cairo_destroy(cr);
+	g_object_unref(pix);
+	return s;
+}
+
+static void tile_free(gpointer data) {
+	struct tile *t = data;
+	if (t->surface)
+		cairo_surface_destroy(t->surface);
+	g_free(t);
+}
+
+static gboolean tile_visible(struct tile *t) {
+	int w = gtk_widget_get_allocated_width(app.map);
+	int h = gtk_widget_get_allocated_height(app.map);
+	int n = 1 << app.zoom;
+	int x0, x1, y0, y1, x;
+	if (t->z != app.zoom)
+		return FALSE;
+	x0 = (int)floor((app.cx - w / 2.0) / TILE_SIZE);
+	x1 = (int)floor((app.cx + w / 2.0) / TILE_SIZE);
+	y0 = (int)floor((app.cy - h / 2.0) / TILE_SIZE);
+	y1 = (int)floor((app.cy + h / 2.0) / TILE_SIZE);
+	if (t->y < y0 || t->y > y1)
+		return FALSE;
+	for (x = x0; x <= x1; x++)
+		if (((x % n) + n) % n == t->x)
+			return TRUE;
+	return FALSE;
+}
+
+static void dl_pump(void);
+
+static void dl_finished(GObject *source, GAsyncResult *res, gpointer data) {
+	struct dl_request *req = data;
+	struct tile *t = req->tile;
+	GError *err = NULL;
+	GBytes *bytes = soup_session_send_and_read_finish(SOUP_SESSION(source), res, &err);
+
+	app.dl_active--;
+	t->dl_state = DL_FAILED;
+	if (bytes && soup_message_get_status(req->msg) == SOUP_STATUS_OK) {
+		GInputStream *st = g_memory_input_stream_new_from_bytes(bytes);
+		GdkPixbuf *pix = gdk_pixbuf_new_from_stream(st, NULL, NULL);
+		g_object_unref(st);
+		if (pix) {
+			char *path = tile_cache_path(t->z, t->x, t->y);
+			char *dir = g_path_get_dirname(path);
+			g_mkdir_with_parents(dir, 0755);
+			g_file_set_contents(path, g_bytes_get_data(bytes, NULL), g_bytes_get_size(bytes), NULL);
+			g_free(dir);
+			g_free(path);
+			if (t->surface)
+				cairo_surface_destroy(t->surface);
+			t->surface = surface_from_pixbuf(pix);
+			t->stale = 0;
+			t->dl_state = DL_DONE;
+		}
+	} else if (err) {
+		g_warning("Tile %d/%d/%d: %s", t->z, t->x, t->y, err->message);
+	} else {
+		g_warning("Tile %d/%d/%d: HTTP %u", t->z, t->x, t->y, soup_message_get_status(req->msg));
+	}
+	if (err)
+		g_error_free(err);
+	if (bytes)
+		g_bytes_unref(bytes);
+	g_object_unref(req->msg);
+	g_free(req);
+	gtk_widget_queue_draw(app.map);
+	dl_pump();
+}
+
+static void dl_pump(void) {
+	while (app.dl_active < MAX_DOWNLOADS && !g_queue_is_empty(&app.dl_queue)) {
+		struct tile *t = g_queue_pop_head(&app.dl_queue);
+		struct dl_request *req;
+		char *url;
+		if (!tile_visible(t)) {
+			t->dl_state = DL_IDLE; /* may be queued again once it's back in view */
+			continue;
+		}
+		url = g_strdup_printf(TILE_URL, t->z, t->x, t->y);
+		req = g_new0(struct dl_request, 1);
+		req->tile = t;
+		req->msg = soup_message_new("GET", url);
+		g_free(url);
+		t->dl_state = DL_LOADING;
+		app.dl_active++;
+		soup_session_send_and_read_async(app.soup, req->msg, G_PRIORITY_DEFAULT, NULL, dl_finished, req);
+	}
+}
+
+/* Look up a tile, loading it from the disk cache if needed. With fetch set,
+ * a missing or stale tile is queued for download. */
+static struct tile *tile_get(int z, int x, int y, gboolean fetch) {
+	char key[48];
+	struct tile *t;
+	g_snprintf(key, sizeof(key), "%d/%d/%d", z, x, y);
+	t = g_hash_table_lookup(app.tiles, key);
+	if (!t) {
+		char *path = tile_cache_path(z, x, y);
+		GStatBuf st;
+		t = g_new0(struct tile, 1);
+		t->z = z;
+		t->x = x;
+		t->y = y;
+		if (g_stat(path, &st) == 0) {
+			t->surface = surface_from_pixbuf(gdk_pixbuf_new_from_file(path, NULL));
+			t->stale = time(NULL) - st.st_mtime > TILE_MAX_AGE;
+		}
+		g_free(path);
+		g_hash_table_insert(app.tiles, g_strdup(key), t);
+	}
+	t->last_used = app.frame;
+	if (fetch && (!t->surface || t->stale) && t->dl_state == DL_IDLE) {
+		t->dl_state = DL_QUEUED;
+		g_queue_push_tail(&app.dl_queue, t);
+	}
+	return t;
+}
+
+static gboolean tile_evictable(gpointer key, gpointer value, gpointer data) {
+	struct tile *t = value;
+	(void)key;
+	(void)data;
+	return t->dl_state != DL_QUEUED && t->dl_state != DL_LOADING && t->last_used + 1 < app.frame;
+}
+
+static void draw_tile(cairo_t *cr, int z, int x, int y, double sx, double sy) {
+	struct tile *t = tile_get(z, x, y, TRUE);
+	int d;
+	if (t->surface) {
+		cairo_set_source_surface(cr, t->surface, sx, sy);
+		cairo_rectangle(cr, sx, sy, TILE_SIZE, TILE_SIZE);
+		cairo_fill(cr);
+		return;
+	}
+	/* Not available (yet): stretch a lower-zoom tile we already have */
+	for (d = 1; d <= FALLBACK_LEVELS && z - d >= 0; d++) {
+		struct tile *p = tile_get(z - d, x >> d, y >> d, FALSE);
+		if (p->surface) {
+			double scale = 1 << d;
+			cairo_save(cr);
+			cairo_rectangle(cr, sx, sy, TILE_SIZE, TILE_SIZE);
+			cairo_clip(cr);
+			cairo_translate(cr, sx - (x - ((x >> d) << d)) * TILE_SIZE,
+					sy - (y - ((y >> d) << d)) * TILE_SIZE);
+			cairo_scale(cr, scale, scale);
+			cairo_set_source_surface(cr, p->surface, 0, 0);
+			cairo_pattern_set_filter(cairo_get_source(cr), CAIRO_FILTER_BILINEAR);
+			cairo_paint(cr);
+			cairo_restore(cr);
+			return;
+		}
+	}
+}
+
+/* ===== Drawing ===== */
+
+static void draw_track(cairo_t *cr, GArray *pts, double ox, double oy, double ws,
+		double r, double g, double b, double a, double width, gboolean dots) {
+	guint i;
+	if (!pts || !pts->len)
+		return;
+	cairo_set_source_rgba(cr, r, g, b, a);
+	cairo_set_line_width(cr, width);
+	cairo_set_line_join(cr, CAIRO_LINE_JOIN_ROUND);
+	cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND);
+	for (i = 0; i < pts->len; i++) {
+		struct mpoint *p = &g_array_index(pts, struct mpoint, i);
+		double sx = p->x * ws - ox, sy = p->y * ws - oy;
+		if (i)
+			cairo_line_to(cr, sx, sy);
+		else
+			cairo_move_to(cr, sx, sy);
+	}
+	if (pts->len == 1)
+		cairo_rel_line_to(cr, 0, 0); /* round cap makes it a dot */
+	cairo_stroke(cr);
+	if (!dots)
+		return;
+	for (i = 0; i < pts->len; i++) {
+		struct mpoint *p = &g_array_index(pts, struct mpoint, i);
+		cairo_new_sub_path(cr);
+		cairo_arc(cr, p->x * ws - ox, p->y * ws - oy, width * 0.9, 0, 2 * M_PI);
+	}
+	cairo_fill(cr);
+}
+
+static void fit_view(void);
+
+static gboolean map_draw(GtkWidget *w, cairo_t *cr, gpointer data) {
+	int width = gtk_widget_get_allocated_width(w);
+	int height = gtk_widget_get_allocated_height(w);
+	double ws, ox, oy;
+	int n, tx0, tx1, ty0, ty1, tx, ty;
+	gboolean dots;
+	cairo_text_extents_t ext;
+	const char *attrib = "© OpenStreetMap contributors";
+	(void)data;
+
+	if (app.need_fit)
+		fit_view();
+
+	app.frame++;
+	if (g_hash_table_size(app.tiles) > MEM_CACHE_MAX)
+		g_hash_table_foreach_remove(app.tiles, tile_evictable, NULL);
+
+	ws = world_size(app.zoom);
+	n = 1 << app.zoom;
+	/* Whole pixels: tiles drawn at fractional offsets show seams */
+	ox = floor(app.cx - width / 2.0);
+	oy = floor(app.cy - height / 2.0);
+
+	cairo_set_source_rgb(cr, 0.85, 0.85, 0.85);
+	cairo_paint(cr);
+
+	tx0 = (int)floor(ox / TILE_SIZE);
+	tx1 = (int)floor((ox + width) / TILE_SIZE);
+	ty0 = MAX(0, (int)floor(oy / TILE_SIZE));
+	ty1 = MIN(n - 1, (int)floor((oy + height) / TILE_SIZE));
+	for (ty = ty0; ty <= ty1; ty++)
+		for (tx = tx0; tx <= tx1; tx++)
+			draw_tile(cr, app.zoom, ((tx % n) + n) % n, ty, tx * TILE_SIZE - ox, ty * TILE_SIZE - oy);
+	dl_pump();
+
+	dots = app.zoom >= DOTS_ZOOM;
+	if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_gpx)) && app.segments) {
+		guint i;
+		for (i = 0; i < app.segments->len; i++) {
+			const double *c = seg_colors[i % SEG_COLOR_COUNT];
+			draw_track(cr, g_ptr_array_index(app.segments, i), ox, oy, ws,
+				c[0], c[1], c[2], 0.85, 4.0, dots);
+		}
+	}
+	/* Input on top, thin, so it stays visible where the output follows it */
+	if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_show_nmea)))
+		draw_track(cr, app.nmea, ox, oy, ws, 0.1, 0.1, 0.1, 0.75, 1.2, dots);
+
+	if (app.frozen) {
+		double sx = app.frozen_mx * ws - ox, sy = app.frozen_my * ws - oy;
+		cairo_set_line_width(cr, 2.0);
+		cairo_set_source_rgb(cr, 1, 1, 1);
+		cairo_arc(cr, sx, sy, 7, 0, 2 * M_PI);
+		cairo_stroke(cr);
+		cairo_set_line_width(cr, 1.5);
+		cairo_set_source_rgb(cr, 0.8, 0, 0);
+		cairo_arc(cr, sx, sy, 7, 0, 2 * M_PI);
+		cairo_move_to(cr, sx - 11, sy);
+		cairo_line_to(cr, sx + 11, sy);
+		cairo_move_to(cr, sx, sy - 11);
+		cairo_line_to(cr, sx, sy + 11);
+		cairo_stroke(cr);
+	}
+
+	/* Attribution, required by the OSM tile usage policy */
+	cairo_set_font_size(cr, 11);
+	cairo_text_extents(cr, attrib, &ext);
+	cairo_set_source_rgba(cr, 1, 1, 1, 0.75);
+	cairo_rectangle(cr, width - ext.x_advance - 8, height - 17, ext.x_advance + 8, 17);
+	cairo_fill(cr);
+	cairo_set_source_rgb(cr, 0.2, 0.2, 0.2);
+	cairo_move_to(cr, width - ext.x_advance - 4, height - 5);
+	cairo_show_text(cr, attrib);
+	return TRUE;
+}
+
+/* ===== Coordinate bar ===== */
+
+static void update_coord_label(void) {
+	double mx, my, lat, lon;
+	char slat[G_ASCII_DTOSTR_BUF_SIZE], slon[G_ASCII_DTOSTR_BUF_SIZE];
+	char *text;
+	if (app.frozen || app.have_cursor) {
+		mx = app.frozen ? app.frozen_mx : app.cursor_mx;
+		my = app.frozen ? app.frozen_my : app.cursor_my;
+		merc_to_latlon(mx, my, &lat, &lon);
+		/* Always '.' as the decimal separator, whatever the locale */
+		g_ascii_formatd(slat, sizeof(slat), "%.6f", lat);
+		g_ascii_formatd(slon, sizeof(slon), "%.6f", lon);
+		if (app.frozen)
+			text = g_strdup_printf("<b>%s, %s</b>  (frozen \u2013 right-click to release)   zoom %d", slat, slon, app.zoom);
+		else
+			text = g_strdup_printf("%s, %s   zoom %d", slat, slon, app.zoom);
+	} else {
+		text = g_strdup_printf("\u2013   zoom %d", app.zoom);
+	}
+	gtk_label_set_markup(GTK_LABEL(app.coord_label), text);
+	g_free(text);
+}
+
+/* ===== View ===== */
+
+static gboolean bbox_add(GArray *a, double *x0, double *y0, double *x1, double *y1) {
+	guint i;
+	for (i = 0; a && i < a->len; i++) {
+		struct mpoint *p = &g_array_index(a, struct mpoint, i);
+		*x0 = MIN(*x0, p->x);
+		*y0 = MIN(*y0, p->y);
+		*x1 = MAX(*x1, p->x);
+		*y1 = MAX(*y1, p->y);
+	}
+	return a && a->len;
+}
+
+static gboolean track_bbox(double *x0, double *y0, double *x1, double *y1) {
+	gboolean any;
+	guint i;
+	*x0 = *y0 = 1.0;
+	*x1 = *y1 = 0.0;
+	any = bbox_add(app.nmea, x0, y0, x1, y1);
+	for (i = 0; app.segments && i < app.segments->len; i++)
+		any |= bbox_add(g_ptr_array_index(app.segments, i), x0, y0, x1, y1);
+	return any;
+}
+
+static void fit_view(void) {
+	int w = gtk_widget_get_allocated_width(app.map);
+	int h = gtk_widget_get_allocated_height(app.map);
+	double x0, y0, x1, y1;
+	if (w <= 1 || h <= 1)
+		return; /* not realized yet; retried on the next draw */
+	app.need_fit = FALSE;
+	if (!track_bbox(&x0, &y0, &x1, &y1))
+		return;
+	for (app.zoom = MAX_ZOOM; app.zoom > MIN_ZOOM; app.zoom--) {
+		double ws = world_size(app.zoom);
+		if ((x1 - x0) * ws <= w - 40 && (y1 - y0) * ws <= h - 40)
+			break;
+	}
+	app.cx = (x0 + x1) / 2.0 * world_size(app.zoom);
+	app.cy = (y0 + y1) / 2.0 * world_size(app.zoom);
+	update_coord_label();
+}
+
+static void zoom_at(int new_zoom, double sx, double sy) {
+	int w = gtk_widget_get_allocated_width(app.map);
+	int h = gtk_widget_get_allocated_height(app.map);
+	double f;
+	new_zoom = CLAMP(new_zoom, MIN_ZOOM, MAX_ZOOM);
+	if (new_zoom == app.zoom)
+		return;
+	f = ldexp(1.0, new_zoom - app.zoom);
+	app.cx = (app.cx + sx - w / 2.0) * f - (sx - w / 2.0);
+	app.cy = (app.cy + sy - h / 2.0) * f - (sy - h / 2.0);
+	app.zoom = new_zoom;
+	clamp_view();
+	update_coord_label();
+	gtk_widget_queue_draw(app.map);
+}
+
+static gboolean map_scroll(GtkWidget *w, GdkEventScroll *ev, gpointer data) {
+	double dx, dy;
+	(void)w;
+	(void)data;
+	switch (ev->direction) {
+	case GDK_SCROLL_UP:
+		zoom_at(app.zoom + 1, ev->x, ev->y);
+		break;
+	case GDK_SCROLL_DOWN:
+		zoom_at(app.zoom - 1, ev->x, ev->y);
+		break;
+	case GDK_SCROLL_SMOOTH:
+		if (!gdk_event_get_scroll_deltas((GdkEvent *)ev, &dx, &dy))
+			break;
+		app.scroll_accum += dy;
+		while (app.scroll_accum <= -1.0) {
+			app.scroll_accum += 1.0;
+			zoom_at(app.zoom + 1, ev->x, ev->y);
+		}
+		while (app.scroll_accum >= 1.0) {
+			app.scroll_accum -= 1.0;
+			zoom_at(app.zoom - 1, ev->x, ev->y);
+		}
+		break;
+	default:
+		break;
+	}
+	return TRUE;
+}
+
+static gboolean map_button_press(GtkWidget *w, GdkEventButton *ev, gpointer data) {
+	(void)w;
+	(void)data;
+	if (ev->type != GDK_BUTTON_PRESS)
+		return FALSE;
+	if (ev->button == 1) {
+		app.pressed = TRUE;
+		app.dragging = FALSE;
+		app.press_x = app.last_x = ev->x;
+		app.press_y = app.last_y = ev->y;
+	} else if (ev->button == 3 && app.frozen) {
+		app.frozen = FALSE;
+		update_coord_label();
+		gtk_widget_queue_draw(app.map);
+	}
+	return TRUE;
+}
+
+static gboolean map_button_release(GtkWidget *w, GdkEventButton *ev, gpointer data) {
+	(void)w;
+	(void)data;
+	if (ev->button != 1 || !app.pressed)
+		return FALSE;
+	app.pressed = FALSE;
+	if (!app.dragging) {
+		screen_to_merc(ev->x, ev->y, &app.frozen_mx, &app.frozen_my);
+		app.frozen = TRUE;
+		update_coord_label();
+		gtk_widget_queue_draw(app.map);
+	}
+	app.dragging = FALSE;
+	return TRUE;
+}
+
+static gboolean map_motion(GtkWidget *w, GdkEventMotion *ev, gpointer data) {
+	(void)w;
+	(void)data;
+	if (app.pressed) {
+		if (!app.dragging && (fabs(ev->x - app.press_x) > CLICK_SLOP || fabs(ev->y - app.press_y) > CLICK_SLOP))
+			app.dragging = TRUE;
+		if (app.dragging) {
+			app.cx -= ev->x - app.last_x;
+			app.cy -= ev->y - app.last_y;
+			clamp_view();
+			gtk_widget_queue_draw(app.map);
+		}
+		app.last_x = ev->x;
+		app.last_y = ev->y;
+	}
+	screen_to_merc(ev->x, ev->y, &app.cursor_mx, &app.cursor_my);
+	app.have_cursor = TRUE;
+	if (!app.frozen)
+		update_coord_label();
+	return TRUE;
+}
+
+static gboolean map_leave(GtkWidget *w, GdkEventCrossing *ev, gpointer data) {
+	(void)w;
+	(void)ev;
+	(void)data;
+	app.have_cursor = FALSE;
+	if (!app.frozen)
+		update_coord_label();
+	return FALSE;
+}
+
+/* ===== Input / output parsing ===== */
+
+static double nmea_coord(const char *val, const char *hemi) {
+	double v = g_ascii_strtod(val, NULL);
+	double deg = floor(v / 100.0);
+	v = deg + (v - deg * 100.0) / 60.0;
+	return (*hemi == 'S' || *hemi == 'W') ? -v : v;
+}
+
+/* Extract a position from a valid, checksummed $..RMC sentence */
+static gboolean nmea_rmc_point(const char *line, struct mpoint *p) {
+	const char *s = strchr(line, '$'), *c;
+	char **f;
+	unsigned char sum = 0;
+	unsigned int want;
+	gboolean ok = FALSE;
+	char *body;
+
+	if (!s || strncmp(s + 3, "RMC,", 4))
+		return FALSE;
+	for (c = s + 1; *c && *c != '*'; c++)
+		sum ^= (unsigned char)*c;
+	if (*c != '*' || sscanf(c + 1, "%2x", &want) != 1 || want != sum)
+		return FALSE;
+	body = g_strndup(s + 1, c - s - 1);
+	f = g_strsplit(body, ",", 0);
+	if (g_strv_length(f) >= 7 && !strcmp(f[2], "A") && *f[3] && *f[5]) {
+		latlon_to_merc(nmea_coord(f[3], f[4]), nmea_coord(f[5], f[6]), p);
+		ok = TRUE;
+	}
+	g_strfreev(f);
+	g_free(body);
+	return ok;
+}
+
+static gboolean load_nmea(const char *path) {
+	char *data, **lines;
+	guint i;
+	GError *err = NULL;
+	if (!g_file_get_contents(path, &data, NULL, &err)) {
+		GtkWidget *d = gtk_message_dialog_new(GTK_WINDOW(app.window), GTK_DIALOG_MODAL,
+			GTK_MESSAGE_ERROR, GTK_BUTTONS_CLOSE, "Cannot open %s: %s", path, err->message);
+		gtk_dialog_run(GTK_DIALOG(d));
+		gtk_widget_destroy(d);
+		g_error_free(err);
+		return FALSE;
+	}
+	if (app.nmea)
+		g_array_free(app.nmea, TRUE);
+	app.nmea = g_array_new(FALSE, FALSE, sizeof(struct mpoint));
+	lines = g_strsplit(data, "\n", 0);
+	for (i = 0; lines[i]; i++) {
+		struct mpoint p;
+		if (nmea_rmc_point(lines[i], &p))
+			g_array_append_val(app.nmea, p);
+	}
+	g_strfreev(lines);
+	g_free(data);
+	return TRUE;
+}
+
+static void free_segments(void) {
+	if (app.segments)
+		g_ptr_array_free(app.segments, TRUE);
+	app.segments = NULL;
+	app.gpx_points = 0;
+}
+
+static void load_gpx(const char *path) {
+	char *data, *s;
+	GArray *seg = NULL;
+	free_segments();
+	app.segments = g_ptr_array_new_with_free_func((GDestroyNotify)g_array_unref);
+	if (!g_file_get_contents(path, &data, NULL, NULL))
+		return;
+	for (s = data; (s = strchr(s, '<')); s++) {
+		if (!strncmp(s, "<trkseg>", 8)) {
+			seg = g_array_new(FALSE, FALSE, sizeof(struct mpoint));
+			g_ptr_array_add(app.segments, seg);
+		} else if (!strncmp(s, "<trkpt ", 7) && seg) {
+			const char *la = strstr(s, "lat=\""), *lo = strstr(s, "lon=\""), *end = strchr(s, '>');
+			struct mpoint p;
+			if (!la || !lo || !end || la > end || lo > end)
+				continue;
+			latlon_to_merc(g_ascii_strtod(la + 5, NULL), g_ascii_strtod(lo + 5, NULL), &p);
+			g_array_append_val(seg, p);
+			app.gpx_points++;
+		}
+	}
+	g_free(data);
+}
+
+static void load_summary(const char *path) {
+	char *data, *s;
+	if (!g_file_get_contents(path, &data, NULL, NULL)) {
+		gtk_label_set_text(GTK_LABEL(app.summary_label), "");
+		return;
+	}
+	s = strstr(data, "=== Summary ===\n");
+	gtk_label_set_text(GTK_LABEL(app.summary_label), s ? g_strstrip(s + 16) : "");
+	g_free(data);
+}
+
+/* ===== Processing (runs the command-line tool) ===== */
+
+static void set_status(const char *text) {
+	gtk_label_set_text(GTK_LABEL(app.status_label), text);
+}
+
+static void start_processing(void);
+
+static void child_exited(GPid pid, gint status, gpointer data) {
+	GError *err = NULL;
+	(void)data;
+	g_spawn_close_pid(pid);
+	app.running = FALSE;
+	if (app.pending) {
+		start_processing();
+		return;
+	}
+	if (!g_spawn_check_wait_status(status, &err)) {
+		char *msg = g_strdup_printf("Processing failed: %s", err->message);
+		set_status(msg);
+		g_free(msg);
+		g_error_free(err);
+		app.have_result = FALSE;
+		free_segments();
+	} else {
+		char *msg;
+		load_gpx(app.out_gpx);
+		load_summary(app.out_log);
+		app.have_result = TRUE;
+		msg = g_strdup_printf("%u NMEA fixes → %" G_GSIZE_FORMAT " GPX points in %u segment(s)",
+			app.nmea ? app.nmea->len : 0, app.gpx_points, app.segments->len);
+		set_status(msg);
+		g_free(msg);
+	}
+	gtk_widget_set_sensitive(app.save_button, app.have_result);
+	gtk_widget_queue_draw(app.map);
+}
+
+static void start_processing(void) {
+	GPtrArray *argv;
+	GError *err = NULL;
+	char *vals[SPIN_COUNT];
+	unsigned i;
+
+	if (!app.input_file)
+		return;
+	if (app.running) {
+		/* Restart with the new settings once this run is gone */
+		app.pending = TRUE;
+		kill(app.child, SIGTERM);
+		return;
+	}
+	app.pending = FALSE;
+
+	argv = g_ptr_array_new();
+	g_ptr_array_add(argv, app.cli_path);
+	g_ptr_array_add(argv, "-o");
+	g_ptr_array_add(argv, app.out_gpx);
+	g_ptr_array_add(argv, "-l");
+	g_ptr_array_add(argv, app.out_log);
+	if (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_disable_filters)))
+		g_ptr_array_add(argv, "-n");
+	if (!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause)))
+		g_ptr_array_add(argv, "-A");
+	for (i = 0; i < SPIN_COUNT; i++) {
+		vals[i] = g_strdup_printf("%d", gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(spins[i].widget)));
+		g_ptr_array_add(argv, (gpointer)spins[i].cli_opt);
+		g_ptr_array_add(argv, vals[i]);
+	}
+	g_ptr_array_add(argv, "--");
+	g_ptr_array_add(argv, app.input_file);
+	g_ptr_array_add(argv, NULL);
+
+	if (g_spawn_async(NULL, (char **)argv->pdata, NULL,
+			G_SPAWN_DO_NOT_REAP_CHILD | G_SPAWN_STDOUT_TO_DEV_NULL,
+			NULL, NULL, &app.child, &err)) {
+		app.running = TRUE;
+		g_child_watch_add(app.child, child_exited, NULL);
+		set_status("Processing…");
+	} else {
+		char *msg = g_strdup_printf("Cannot run %s: %s", app.cli_path, err->message);
+		set_status(msg);
+		g_free(msg);
+		g_error_free(err);
+	}
+	for (i = 0; i < SPIN_COUNT; i++)
+		g_free(vals[i]);
+	g_ptr_array_free(argv, TRUE);
+}
+
+static gboolean reprocess_timeout(gpointer data) {
+	(void)data;
+	app.reprocess_id = 0;
+	start_processing();
+	return G_SOURCE_REMOVE;
+}
+
+static void setting_changed(GtkWidget *w, gpointer data) {
+	unsigned i;
+	(void)w;
+	(void)data;
+	if (app.loading_settings)
+		return;
+	for (i = SPIN_AP_FIRST; i < SPIN_COUNT; i++)
+		gtk_widget_set_sensitive(spins[i].widget,
+			gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause)));
+	settings_store();
+	/* Debounced: holding a spin button's arrow fires many changes */
+	if (app.reprocess_id)
+		g_source_remove(app.reprocess_id);
+	app.reprocess_id = g_timeout_add(REPROCESS_DELAY_MS, reprocess_timeout, NULL);
+}
+
+static void view_changed(GtkWidget *w, gpointer data) {
+	(void)w;
+	(void)data;
+	settings_store();
+	gtk_widget_queue_draw(app.map);
+}
+
+/* ===== File handling ===== */
+
+static void open_file(const char *path) {
+	char *title, *base;
+	if (!load_nmea(path))
+		return;
+	g_free(app.input_file);
+	app.input_file = g_canonicalize_filename(path, NULL);
+	g_key_file_set_string(app.config, "view", "input_file", app.input_file);
+	config_save();
+	base = g_path_get_basename(path);
+	title = g_strdup_printf("%s – GPS Test Tool", base);
+	gtk_window_set_title(GTK_WINDOW(app.window), title);
+	g_free(title);
+	g_free(base);
+	free_segments();
+	app.have_result = FALSE;
+	gtk_widget_set_sensitive(app.save_button, FALSE);
+	gtk_label_set_text(GTK_LABEL(app.summary_label), "");
+	app.need_fit = TRUE;
+	gtk_widget_queue_draw(app.map);
+	start_processing();
+}
+
+static void open_clicked(GtkWidget *w, gpointer data) {
+	GtkWidget *d;
+	GtkFileFilter *f;
+	(void)w;
+	(void)data;
+	d = gtk_file_chooser_dialog_new("Open NMEA log", GTK_WINDOW(app.window),
+		GTK_FILE_CHOOSER_ACTION_OPEN, "_Cancel", GTK_RESPONSE_CANCEL,
+		"_Open", GTK_RESPONSE_ACCEPT, NULL);
+	f = gtk_file_filter_new();
+	gtk_file_filter_set_name(f, "NMEA logs");
+	gtk_file_filter_add_pattern(f, "*.nmea");
+	gtk_file_filter_add_pattern(f, "*.NMEA");
+	gtk_file_filter_add_pattern(f, "*.log");
+	gtk_file_filter_add_pattern(f, "*.LOG");
+	gtk_file_filter_add_pattern(f, "*.txt");
+	gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(d), f);
+	f = gtk_file_filter_new();
+	gtk_file_filter_set_name(f, "All files");
+	gtk_file_filter_add_pattern(f, "*");
+	gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(d), f);
+	if (app.input_file) {
+		char *dir = g_path_get_dirname(app.input_file);
+		gtk_file_chooser_set_current_folder(GTK_FILE_CHOOSER(d), dir);
+		g_free(dir);
+	}
+	if (gtk_dialog_run(GTK_DIALOG(d)) == GTK_RESPONSE_ACCEPT) {
+		char *path = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(d));
+		gtk_widget_destroy(d);
+		open_file(path);
+		g_free(path);
+		return;
+	}
+	gtk_widget_destroy(d);
+}
+
+static void save_clicked(GtkWidget *w, gpointer data) {
+	GtkWidget *d;
+	(void)w;
+	(void)data;
+	if (!app.have_result)
+		return;
+	d = gtk_file_chooser_dialog_new("Save GPX", GTK_WINDOW(app.window),
+		GTK_FILE_CHOOSER_ACTION_SAVE, "_Cancel", GTK_RESPONSE_CANCEL,
+		"_Save", GTK_RESPONSE_ACCEPT, NULL);
+	gtk_file_chooser_set_do_overwrite_confirmation(GTK_FILE_CHOOSER(d), TRUE);
+	if (app.input_file) {
+		char *dir = g_path_get_dirname(app.input_file);
+		char *base = g_path_get_basename(app.input_file);
+		char *dot = strrchr(base, '.');
+		char *name;
+		if (dot)
+			*dot = '\0';
+		name = g_strdup_printf("%s.gpx", base);
+		gtk_file_chooser_set_current_folder(GTK_FILE_CHOOSER(d), dir);
+		gtk_file_chooser_set_current_name(GTK_FILE_CHOOSER(d), name);
+		g_free(name);
+		g_free(base);
+		g_free(dir);
+	}
+	if (gtk_dialog_run(GTK_DIALOG(d)) == GTK_RESPONSE_ACCEPT) {
+		GFile *src = g_file_new_for_path(app.out_gpx);
+		GFile *dst = gtk_file_chooser_get_file(GTK_FILE_CHOOSER(d));
+		GError *err = NULL;
+		if (!g_file_copy(src, dst, G_FILE_COPY_OVERWRITE, NULL, NULL, NULL, &err)) {
+			GtkWidget *m = gtk_message_dialog_new(GTK_WINDOW(d), GTK_DIALOG_MODAL,
+				GTK_MESSAGE_ERROR, GTK_BUTTONS_CLOSE, "Cannot save: %s", err->message);
+			gtk_dialog_run(GTK_DIALOG(m));
+			gtk_widget_destroy(m);
+			g_error_free(err);
+		}
+		g_object_unref(src);
+		g_object_unref(dst);
+	}
+	gtk_widget_destroy(d);
+}
+
+static void fit_clicked(GtkWidget *w, gpointer data) {
+	(void)w;
+	(void)data;
+	app.need_fit = TRUE;
+	gtk_widget_queue_draw(app.map);
+}
+
+/* ===== Setup ===== */
+
+static char *find_cli(void) {
+	char *self = g_file_read_link("/proc/self/exe", NULL);
+	if (self) {
+		char *dir = g_path_get_dirname(self);
+		char *path = g_build_filename(dir, "gps-test-tool", NULL);
+		g_free(dir);
+		g_free(self);
+		if (g_file_test(path, G_FILE_TEST_IS_EXECUTABLE))
+			return path;
+		g_free(path);
+	}
+	return g_find_program_in_path("gps-test-tool");
+}
+
+static GtkWidget *section(GtkWidget *box, const char *title) {
+	GtkWidget *frame = gtk_frame_new(title);
+	GtkWidget *inner = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
+	gtk_container_set_border_width(GTK_CONTAINER(inner), 6);
+	gtk_container_add(GTK_CONTAINER(frame), inner);
+	gtk_box_pack_start(GTK_BOX(box), frame, FALSE, FALSE, 0);
+	return inner;
+}
+
+static GtkWidget *check(GtkWidget *box, const char *label, const char *group, const char *key,
+		gboolean def, GCallback cb) {
+	GtkWidget *w = gtk_check_button_new_with_label(label);
+	GError *err = NULL;
+	gboolean v = g_key_file_get_boolean(app.config, group, key, &err);
+	if (err) {
+		g_error_free(err);
+		v = def;
+	}
+	gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(w), v);
+	g_signal_connect(w, "toggled", cb, NULL);
+	gtk_box_pack_start(GTK_BOX(box), w, FALSE, FALSE, 0);
+	return w;
+}
+
+static void spin(GtkWidget *box, struct setting_spin *s) {
+	GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
+	int v = CLAMP(config_get_int("settings", s->key, s->def), s->min, s->max);
+	s->widget = gtk_spin_button_new_with_range(s->min, s->max, 1);
+	gtk_spin_button_set_value(GTK_SPIN_BUTTON(s->widget), v);
+	g_signal_connect(s->widget, "value-changed", G_CALLBACK(setting_changed), NULL);
+	gtk_box_pack_start(GTK_BOX(row), gtk_label_new(s->label), FALSE, FALSE, 0);
+	gtk_box_pack_end(GTK_BOX(row), s->widget, FALSE, FALSE, 0);
+	gtk_box_pack_start(GTK_BOX(box), row, FALSE, FALSE, 0);
+}
+
+static void build_ui(void) {
+	GtkWidget *header, *button, *hbox, *side, *sec, *vbox, *bar, *scroll;
+	PangoAttrList *attrs;
+	unsigned i;
+
+	app.window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
+	gtk_window_set_default_size(GTK_WINDOW(app.window),
+		config_get_int("view", "width", 1200), config_get_int("view", "height", 800));
+	g_signal_connect(app.window, "destroy", G_CALLBACK(gtk_main_quit), NULL);
+
+	header = gtk_header_bar_new();
+	gtk_header_bar_set_show_close_button(GTK_HEADER_BAR(header), TRUE);
+	gtk_header_bar_set_title(GTK_HEADER_BAR(header), "GPS Test Tool");
+	button = gtk_button_new_with_mnemonic("_Open NMEA…");
+	g_signal_connect(button, "clicked", G_CALLBACK(open_clicked), NULL);
+	gtk_header_bar_pack_start(GTK_HEADER_BAR(header), button);
+	app.save_button = gtk_button_new_with_mnemonic("_Save GPX…");
+	gtk_widget_set_sensitive(app.save_button, FALSE);
+	g_signal_connect(app.save_button, "clicked", G_CALLBACK(save_clicked), NULL);
+	gtk_header_bar_pack_start(GTK_HEADER_BAR(header), app.save_button);
+	gtk_window_set_titlebar(GTK_WINDOW(app.window), header);
+
+	vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
+	hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
+	gtk_box_pack_start(GTK_BOX(vbox), hbox, TRUE, TRUE, 0);
+
+	side = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
+	gtk_container_set_border_width(GTK_CONTAINER(side), 8);
+	scroll = gtk_scrolled_window_new(NULL, NULL);
+	gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
+	gtk_container_add(GTK_CONTAINER(scroll), side);
+	gtk_widget_set_size_request(scroll, 270, -1);
+	gtk_box_pack_start(GTK_BOX(hbox), scroll, FALSE, FALSE, 0);
+	gtk_box_pack_start(GTK_BOX(hbox), gtk_separator_new(GTK_ORIENTATION_VERTICAL), FALSE, FALSE, 0);
+
+	app.loading_settings = TRUE;
+	sec = section(side, "Filtering");
+	app.chk_disable_filters = check(sec, "Disable filters", "settings", "disable_filters", FALSE, G_CALLBACK(setting_changed));
+	spin(sec, &spins[SPIN_SKIP]);
+	spin(sec, &spins[SPIN_MIN_SATS]);
+
+	sec = section(side, "Auto-pause");
+	app.chk_auto_pause = check(sec, "Enabled", "settings", "auto_pause", TRUE, G_CALLBACK(setting_changed));
+	for (i = SPIN_AP_FIRST; i < SPIN_COUNT; i++) {
+		spin(sec, &spins[i]);
+		gtk_widget_set_sensitive(spins[i].widget,
+			gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.chk_auto_pause)));
+	}
+
+	sec = section(side, "Display");
+	app.chk_show_nmea = check(sec, "NMEA input (grey)", "view", "show_nmea", TRUE, G_CALLBACK(view_changed));
+	app.chk_show_gpx = check(sec, "GPX output (colour per segment)", "view", "show_gpx", TRUE, G_CALLBACK(view_changed));
+	button = gtk_button_new_with_label("Zoom to fit");
+	g_signal_connect(button, "clicked", G_CALLBACK(fit_clicked), NULL);
+	gtk_box_pack_start(GTK_BOX(sec), button, FALSE, FALSE, 0);
+	app.loading_settings = FALSE;
+
+	sec = section(side, "Summary");
+	app.summary_label = gtk_label_new("");
+	gtk_label_set_selectable(GTK_LABEL(app.summary_label), TRUE);
+	gtk_label_set_xalign(GTK_LABEL(app.summary_label), 0);
+	gtk_label_set_line_wrap(GTK_LABEL(app.summary_label), TRUE);
+	attrs = pango_attr_list_new();
+	pango_attr_list_insert(attrs, pango_attr_family_new("monospace"));
+	pango_attr_list_insert(attrs, pango_attr_scale_new(0.85));
+	gtk_label_set_attributes(GTK_LABEL(app.summary_label), attrs);
+	pango_attr_list_unref(attrs);
+	gtk_box_pack_start(GTK_BOX(sec), app.summary_label, FALSE, FALSE, 0);
+
+	app.map = gtk_drawing_area_new();
+	gtk_widget_add_events(app.map, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK |
+		GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK | GDK_SMOOTH_SCROLL_MASK | GDK_LEAVE_NOTIFY_MASK);
+	g_signal_connect(app.map, "draw", G_CALLBACK(map_draw), NULL);
+	g_signal_connect(app.map, "scroll-event", G_CALLBACK(map_scroll), NULL);
+	g_signal_connect(app.map, "button-press-event", G_CALLBACK(map_button_press), NULL);
+	g_signal_connect(app.map, "button-release-event", G_CALLBACK(map_button_release), NULL);
+	g_signal_connect(app.map, "motion-notify-event", G_CALLBACK(map_motion), NULL);
+	g_signal_connect(app.map, "leave-notify-event", G_CALLBACK(map_leave), NULL);
+	gtk_box_pack_start(GTK_BOX(hbox), app.map, TRUE, TRUE, 0);
+
+	bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
+	gtk_container_set_border_width(GTK_CONTAINER(bar), 4);
+	app.coord_label = gtk_label_new("");
+	gtk_label_set_selectable(GTK_LABEL(app.coord_label), TRUE);
+	gtk_box_pack_start(GTK_BOX(bar), app.coord_label, FALSE, FALSE, 4);
+	app.status_label = gtk_label_new("Open an NMEA log to start");
+	gtk_box_pack_end(GTK_BOX(bar), app.status_label, FALSE, FALSE, 4);
+	gtk_box_pack_start(GTK_BOX(vbox), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL), FALSE, FALSE, 0);
+	gtk_box_pack_start(GTK_BOX(vbox), bar, FALSE, FALSE, 0);
+
+	gtk_container_add(GTK_CONTAINER(app.window), vbox);
+}
+
+static gboolean window_configure(GtkWidget *w, GdkEvent *ev, gpointer data) {
+	int width, height;
+	(void)ev;
+	(void)data;
+	gtk_window_get_size(GTK_WINDOW(w), &width, &height);
+	g_key_file_set_integer(app.config, "view", "width", width);
+	g_key_file_set_integer(app.config, "view", "height", height);
+	return FALSE;
+}
+
+static gboolean quit_signal(gpointer data) {
+	(void)data;
+	gtk_main_quit();
+	return G_SOURCE_REMOVE;
+}
+
+int main(int argc, char *argv[]) {
+	char *input;
+
+	gtk_init(&argc, &argv);
+
+	app.config = g_key_file_new();
+	app.config_path = g_build_filename(g_get_user_config_dir(), "gps-test-tool", "gui.ini", NULL);
+	g_key_file_load_from_file(app.config, app.config_path, G_KEY_FILE_NONE, NULL);
+	app.cache_dir = g_build_filename(g_get_user_cache_dir(), "gps-test-tool", "tiles", NULL);
+
+	app.cli_path = find_cli();
+	app.tmpdir = g_dir_make_tmp("gps-test-tool-gui-XXXXXX", NULL);
+	if (!app.cli_path || !app.tmpdir) {
+		fprintf(stderr, "Error: %s\n", app.cli_path ? "cannot create temporary directory"
+			: "gps-test-tool binary not found next to this program or in PATH");
+		return 1;
+	}
+	app.out_gpx = g_build_filename(app.tmpdir, "output.gpx", NULL);
+	app.out_log = g_build_filename(app.tmpdir, "debug.txt", NULL);
+
+	app.tiles = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, tile_free);
+	g_queue_init(&app.dl_queue);
+	app.soup = soup_session_new_with_options("user-agent", USER_AGENT,
+		"max-conns-per-host", MAX_DOWNLOADS, NULL);
+	app.zoom = MIN_ZOOM;
+	app.cx = app.cy = world_size(app.zoom) / 2;
+
+	build_ui();
+	g_signal_connect(app.window, "configure-event", G_CALLBACK(window_configure), NULL);
+	gtk_widget_show_all(app.window);
+	update_coord_label();
+
+	input = argc > 1 ? g_strdup(argv[1]) : g_key_file_get_string(app.config, "view", "input_file", NULL);
+	if (input && (argc > 1 || g_file_test(input, G_FILE_TEST_EXISTS)))
+		open_file(input);
+	g_free(input);
+
+	/* Clean up the temporary directory on Ctrl+C / kill too */
+	g_unix_signal_add(SIGINT, quit_signal, NULL);
+	g_unix_signal_add(SIGTERM, quit_signal, NULL);
+	gtk_main();
+
+	if (app.running)
+		kill(app.child, SIGTERM);
+	config_save();
+	g_unlink(app.out_gpx);
+	g_unlink(app.out_log);
+	g_rmdir(app.tmpdir);
+	return 0;
+}

+ 84 - 23
gps-test-tool/main.c

@@ -2,17 +2,21 @@
  * GPS Test Tool - PC Application
  *
  * Tests GPS filtering and GPX generation using actual embedded code
- * Usage: ./gps-test-tool <input.nmea>
+ * Usage: ./gps-test-tool [options] <input.nmea>
  *
- * Generates:
+ * Generates (default mode):
  *   - output_filtered.gpx
  *   - output_unfiltered.gpx
  *   - debug_filtered.txt
  *   - debug_unfiltered.txt
+ *
+ * With -o, does a single run with the given settings instead (used by
+ * gps-test-tool-gui).
  */
 
 #define PC_BUILD
 #include "main.h"
+#include <getopt.h>
 
 /* Global variables expected by project code */
 volatile struct system_s System;
@@ -63,17 +67,26 @@ void iso_time_to_filename(char *time) {
     }
 }
 
+/* Settings applied by reset_system(); command-line options override these. */
+static unsigned char opt_skip_points = 0;  /* Don't skip points for testing */
+/* Match soft/settings.c's defaults_u8, so this tool's auto-pause behavior
+ * matches an out-of-the-box device. */
+static unsigned char opt_auto_pause_time = 30;
+static unsigned char opt_auto_pause_dist = 10;
+static unsigned char opt_auto_pause_speed = 3;
+static unsigned char opt_min_sats = 4;  /* Require at least 4 satellites */
+static unsigned char opt_auto_pause = 1;
+static unsigned char opt_verbose_log = 0;
+
 /* Reset system state */
 void reset_system(void) {
     memset((void*)&System, 0, sizeof(System));
     memset(&location, 0, sizeof(location));
-    System.conf.skip_points = 0;  /* Don't skip points for testing */
-    /* Match soft/settings.c's defaults_u8, so this tool's auto-pause behavior
-     * (enabled below) matches an out-of-the-box device. */
-    System.conf.auto_pause_time = 30;
-    System.conf.auto_pause_dist = 10;
-    System.conf.auto_pause_speed = 3;
-    System.conf.min_sats = 4;  /* Require at least 4 satellites */
+    System.conf.skip_points = opt_skip_points;
+    System.conf.auto_pause_time = opt_auto_pause_time;
+    System.conf.auto_pause_dist = opt_auto_pause_dist;
+    System.conf.auto_pause_speed = opt_auto_pause_speed;
+    System.conf.min_sats = opt_min_sats;
     System.tracking_paused = 0;
     System.tracking_auto_paused = 0;
     System.bat_volt = 4.0f;   /* no real ADC here; keeps the status line's v=/bat%= fields sane */
@@ -109,7 +122,8 @@ int process_nmea_file(const char *input_file, const char *output_gpx,
 
     /* Configure filters */
     set_flag(CONFFLAG_DISABLE_FILTERS, !filters_enabled);
-    set_flag(CONFFLAG_AUTO_PAUSE, 1);
+    set_flag(CONFFLAG_AUTO_PAUSE, opt_auto_pause);
+    set_flag(CONFFLAG_VERBOSE_LOG, opt_verbose_log);
 
     gps_powered_on();
     log_boot_record();
@@ -260,25 +274,72 @@ int process_nmea_file(const char *input_file, const char *output_gpx,
     return 0;
 }
 
-int main(int argc, char *argv[]) {
-    int ret;
+static void usage(const char *name) {
+    fprintf(stderr, "Usage: %s [options] <input.nmea>\n", name);
+    fprintf(stderr, "\nWithout -o, generates:\n");
+    fprintf(stderr, "  - output_filtered.gpx\n");
+    fprintf(stderr, "  - output_unfiltered.gpx\n");
+    fprintf(stderr, "  - debug_filtered.txt\n");
+    fprintf(stderr, "  - debug_unfiltered.txt\n");
+    fprintf(stderr, "\nOptions:\n");
+    fprintf(stderr, "  -o FILE  single run: write GPX to FILE only\n");
+    fprintf(stderr, "  -l FILE  single run: write debug output to FILE (default: none)\n");
+    fprintf(stderr, "  -n       single run: disable filters\n");
+    fprintf(stderr, "  -A       disable auto-pause\n");
+    fprintf(stderr, "  -t SEC   auto-pause time (default %u)\n", opt_auto_pause_time);
+    fprintf(stderr, "  -d M     auto-pause distance (default %u)\n", opt_auto_pause_dist);
+    fprintf(stderr, "  -s KMH   auto-pause speed (default %u)\n", opt_auto_pause_speed);
+    fprintf(stderr, "  -k N     skip points after fix (default %u)\n", opt_skip_points);
+    fprintf(stderr, "  -m N     minimum satellites (default %u)\n", opt_min_sats);
+    fprintf(stderr, "  -v       verbose log\n");
+}
 
-    printf("GPS Test Tool v1.0\n");
-    printf("==================\n\n");
+/* Parse a 0..255 setting value; returns 0 on success. */
+static int parse_u8(const char *str, unsigned char *val) {
+    char *end;
+    long v = strtol(str, &end, 10);
+    if (!*str || *end || v < 0 || v > 255)
+        return 1;
+    *val = v;
+    return 0;
+}
 
-    if (argc != 2) {
-        fprintf(stderr, "Usage: %s <input.nmea>\n", argv[0]);
-        fprintf(stderr, "\nGenerates:\n");
-        fprintf(stderr, "  - output_filtered.gpx\n");
-        fprintf(stderr, "  - output_unfiltered.gpx\n");
-        fprintf(stderr, "  - debug_filtered.txt\n");
-        fprintf(stderr, "  - debug_unfiltered.txt\n");
+int main(int argc, char *argv[]) {
+    int ret, c, bad = 0;
+    const char *single_gpx = NULL;
+    const char *single_debug = "/dev/null";
+    int single_filters = 1;
+
+    while ((c = getopt(argc, argv, "o:l:nAt:d:s:k:m:v")) != -1) {
+        switch (c) {
+            case 'o': single_gpx = optarg; break;
+            case 'l': single_debug = optarg; break;
+            case 'n': single_filters = 0; break;
+            case 'A': opt_auto_pause = 0; break;
+            case 't': bad |= parse_u8(optarg, &opt_auto_pause_time); break;
+            case 'd': bad |= parse_u8(optarg, &opt_auto_pause_dist); break;
+            case 's': bad |= parse_u8(optarg, &opt_auto_pause_speed); break;
+            case 'k': bad |= parse_u8(optarg, &opt_skip_points); break;
+            case 'm': bad |= parse_u8(optarg, &opt_min_sats); break;
+            case 'v': opt_verbose_log = 1; break;
+            default: bad = 1; break;
+        }
+    }
+
+    if (bad || optind != argc - 1) {
+        usage(argv[0]);
         return 1;
     }
 
+    if (single_gpx)
+        return process_nmea_file(argv[optind], single_gpx, single_debug, single_filters);
+
+    printf("GPS Test Tool v1.0\n");
+    printf("==================\n\n");
+
     /* Process with filters enabled */
     printf("Processing with filters ENABLED...\n");
-    ret = process_nmea_file(argv[1], "output_filtered.gpx",
+    ret = process_nmea_file(argv[optind], "output_filtered.gpx",
                             "debug_filtered.txt", 1);
     if (ret != 0) {
         fprintf(stderr, "Failed to process with filters enabled\n");
@@ -287,7 +348,7 @@ int main(int argc, char *argv[]) {
 
     /* Process with filters disabled */
     printf("Processing with filters DISABLED...\n");
-    ret = process_nmea_file(argv[1], "output_unfiltered.gpx",
+    ret = process_nmea_file(argv[optind], "output_unfiltered.gpx",
                             "debug_unfiltered.txt", 0);
     if (ret != 0) {
         fprintf(stderr, "Failed to process with filters disabled\n");

+ 61 - 25
soft/autopause.c

@@ -31,6 +31,29 @@ struct auto_pause_s auto_pause;
 #define AUTO_PAUSE_RESUME_HISTORY_MASK	((1U << AUTO_PAUSE_RESUME_HISTORY_LEN) - 1)
 #define AUTO_PAUSE_RESUME_MIN_COUNT	5
 
+/* Raw NMEA-reported ground speed (System.speed_x10) is single-epoch Doppler
+ * output with no smoothing - real recorded hikes show it read a few km/h for
+ * several consecutive seconds even while the receiver is stationary and
+ * HDOP/sat count are excellent (multipath/receiver noise, not position
+ * error, and correlated over several seconds rather than independent from
+ * sample to sample). A strict "N consecutive samples over threshold"
+ * debounce is exactly the shape most exposed to this - and even a plain
+ * majority-of-a-window vote on the raw value isn't enough on its own, since
+ * this noise is persistent enough to occasionally dominate a short window
+ * outright. Smooth the speed first (exponential smoothing, same idea as
+ * elevation_process()'s altitude smoothing) to knock down the bulk of the
+ * noise, *then* still require the smoothed value to clear the threshold for
+ * most of a window before resuming - so a single lingering noisy patch
+ * cannot itself resume a genuinely stationary logger, but sustained real
+ * movement (which pushes the smoothed value well past the threshold, not
+ * just brushing it) still resumes within several seconds. */
+#define SPEED_SMOOTH_ALPHA		0.25	/* exponential smoothing factor per sample (~1s apart) */
+#define SPEED_RESUME_HISTORY_LEN	7	/* same window length as the other resume-history checks */
+#define SPEED_RESUME_HISTORY_MASK	((1U << SPEED_RESUME_HISTORY_LEN) - 1)
+#define SPEED_RESUME_MIN_COUNT		6	/* stricter majority than distance/heading: speed alone has no
+						 * corroborating signal (no distance-from-anchor or heading check
+						 * backs it up), so it needs to be almost the whole window */
+
 /* Distance-from-anchor alone can't tell a real departure from a smooth
  * position drift with no real movement behind it: a satellite geometry
  * change (a few satellites dropping out/back in) can walk the computed fix
@@ -43,7 +66,7 @@ struct auto_pause_s auto_pause;
  * HEADING_STEP_MIN_DIST_M: bearing is meaningless noise over a sub-meter step
  * (the two fixes are within GPS jitter of each other), and would otherwise
  * make genuinely stationary jitter look "inconsistent" for the wrong reason. */
-#define HEADING_STEP_MIN_DIST_M		1.5	/* ignore bearing between two samples closer than this */
+#define HEADING_STEP_MIN_DIST_M		3	/* ignore bearing between two samples closer than this */
 #define HEADING_CONSISTENT_MAX_DEG	60	/* max angle from the previous step's bearing to still agree */
 #define HEADING_HISTORY_LEN		7	/* same window length as the distance resume history above */
 #define HEADING_HISTORY_MASK		((1U << HEADING_HISTORY_LEN) - 1)
@@ -62,12 +85,11 @@ static void auto_unpause(void) {
 static void auto_pause_activate(void) {
 	System.tracking_auto_paused = 1;
 	pause_time_start();
-	/* Otherwise, speed-counter progress from just before this decision (e.g.
-	 * 2 of the 3 consecutive above-threshold samples the resume check below
-	 * wants) could carry over and complete on the very next sample,
-	 * instantly flip-flopping straight back out of the pause this just
-	 * activated. */
-	auto_pause.speed_counter = 0;
+	/* Otherwise, speed-history progress from just before this decision could
+	 * carry over and complete on the very next few samples, instantly
+	 * flip-flopping straight back out of the pause this just activated. */
+	auto_pause.speed_high_history = 0;
+	auto_pause.smoothed_speed_valid = 0;
 	/* Anchor the resume-by-distance check on where the pause actually
 	 * started, then let it drift towards the running average of positions
 	 * seen while paused (steadier than any single fix - see
@@ -96,7 +118,8 @@ void auto_pause_clear_manual_resume(void) {
 	System.tracking_auto_paused = 0;
 	auto_pause.prev_distance = System.distance;
 	auto_pause.point_counter = 0;
-	auto_pause.speed_counter = 0;
+	auto_pause.speed_high_history = 0;
+	auto_pause.smoothed_speed_valid = 0;
 }
 
 /* Call once, whenever fix_gap_detected() reports a gap on the point about to
@@ -108,7 +131,8 @@ void auto_pause_clear_manual_resume(void) {
  * threshold is meant to require (persistent time actually stationary). */
 void auto_pause_reset_gap(void) {
 	auto_pause.point_counter = 0;
-	auto_pause.speed_counter = 0;
+	auto_pause.speed_high_history = 0;
+	auto_pause.smoothed_speed_valid = 0;
 	auto_pause.prev_distance = System.distance;
 	if (System.tracking_auto_paused) {
 		/* Same reasoning as auto_pause_activate()'s anchor init: re-anchor on
@@ -130,27 +154,33 @@ void auto_pause_process(void) {
 		System.tracking_auto_paused = 0;
 		auto_pause.prev_distance = System.distance;
 		auto_pause.point_counter = 0;
-		auto_pause.speed_counter = 0;
+		auto_pause.speed_high_history = 0;
+		auto_pause.smoothed_speed_valid = 0;
 		return;
 	}
 	/* A poor/invalid fix must not be read as "moving": a single noisy sample
 	 * (e.g. a low-satellite, high-HDOP fix briefly reporting tens of km/h,
 	 * as seen on a real hike where reception dropped for a few seconds)
-	 * could otherwise satisfy the "3 consecutive" debounce below on its own
-	 * and resume a logger that is actually sitting still. Such a sample is
-	 * simply ignored - neither counted towards, nor resetting, the
-	 * consecutive-samples debounce - so it can't itself trigger a resume,
-	 * but also can't erase real progress made by genuine movement. */
+	 * could otherwise satisfy the persistence check below on its own and
+	 * resume a logger that is actually sitting still. Such a sample is
+	 * simply ignored - it shifts nothing into the history below - so it
+	 * can't itself trigger a resume, but also can't erase real progress
+	 * made by genuine movement. */
 	if (gps_fix_trustworthy()) {
-		if (System.speed_x10 >= (unsigned int)System.conf.auto_pause_speed*10) { /* unpause when set speed is exceeded for 3 consecutive measurements */
-			if (++auto_pause.speed_counter >= 3) {
-				auto_pause.point_counter = 0;
-				auto_pause.speed_counter = 0;
-				auto_unpause();
-				return;
-			}
+		unsigned char speed_high;
+		if (!auto_pause.smoothed_speed_valid) {
+			auto_pause.smoothed_speed_x10 = (float)System.speed_x10;
+			auto_pause.smoothed_speed_valid = 1;
 		} else {
-			auto_pause.speed_counter = 0;
+			auto_pause.smoothed_speed_x10 += SPEED_SMOOTH_ALPHA * ((float)System.speed_x10 - auto_pause.smoothed_speed_x10);
+		}
+		speed_high = auto_pause.smoothed_speed_x10 >= (float)((unsigned int)System.conf.auto_pause_speed*10);
+		auto_pause.speed_high_history = (auto_pause.speed_high_history << 1) | speed_high;
+		if (__builtin_popcount(auto_pause.speed_high_history & SPEED_RESUME_HISTORY_MASK) >= SPEED_RESUME_MIN_COUNT) {
+			auto_pause.point_counter = 0;
+			auto_pause.speed_high_history = 0;
+			auto_unpause();
+			return;
 		}
 	}
 	/* Resume-by-distance: compared against an anchor updated to the running
@@ -203,13 +233,16 @@ void auto_pause_process(void) {
 				}
 				auto_pause.prev_bearing = this_bearing;
 				auto_pause.prev_bearing_valid = 1;
+				auto_pause.prev_location = location;
 			}
 			/* else: too small a step to trust its bearing - leave prev_bearing
 			 * and heading_consistent_history untouched, neither confirming nor
 			 * breaking the run of consistent headings. */
 		}
-		auto_pause.prev_location = location;
-		auto_pause.prev_location_valid = 1;
+		if (!auto_pause.prev_location_valid) {
+			auto_pause.prev_location = location;
+			auto_pause.prev_location_valid = 1;
+		}
 
 		if (__builtin_popcount(auto_pause.resume_high_history & AUTO_PAUSE_RESUME_HISTORY_MASK) >= AUTO_PAUSE_RESUME_MIN_COUNT
 			&& __builtin_popcount(auto_pause.heading_consistent_history & HEADING_HISTORY_MASK) >= HEADING_MIN_COUNT) {
@@ -218,6 +251,8 @@ void auto_pause_process(void) {
 			return;
 		}
 	}
+	if (auto_pause.point_counter == System.conf.auto_pause_time - System.conf.auto_pause_time/4)
+		auto_pause.quarter_distance = System.distance;
 	if (++auto_pause.point_counter < System.conf.auto_pause_time)
 		return;
 	auto_pause.point_counter = 0;
@@ -235,6 +270,7 @@ void auto_pause_process(void) {
 		 * a run of untrustworthy fixes must not be allowed to decide a pause
 		 * transition either way here - it's simply skipped for this window. */
 		if ((System.distance - auto_pause.prev_distance)/100 <= System.conf.auto_pause_dist
+			&& (System.distance - auto_pause.quarter_distance)/100 <= System.conf.auto_pause_dist/4
 			&& System.speed_x10 < (unsigned int)System.conf.auto_pause_speed*10)
 			auto_pause_activate(); /* pause: not enough net movement, and not currently moving fast */
 	}

+ 5 - 2
soft/common.h

@@ -152,10 +152,13 @@ struct system_s {
 struct auto_pause_s {
 	unsigned char point_counter;
 	unsigned long int prev_distance;
-	unsigned char speed_counter;
+	unsigned long int quarter_distance; /* System.distance at 3/4 of the current auto-pause window */
+	float smoothed_speed_x10; /* exponentially-smoothed System.speed_x10, to reject correlated Doppler noise */
+	unsigned char smoothed_speed_valid:1;
+	unsigned char speed_high_history; /* bit history: was smoothed speed over the auto-pause-speed threshold */
 	struct location_s pause_anchor; /* running average position while paused */
 	unsigned int anchor_sample_count;
-	unsigned char resume_high_history; /* bit history: was the anchor-distance/speed resume condition met */
+	unsigned char resume_high_history; /* bit history: was the anchor-distance resume condition met */
 	struct location_s prev_location; /* last sample seen while paused, for step-to-step bearing */
 	unsigned char prev_location_valid:1;
 	unsigned char prev_bearing_valid:1;

+ 86 - 9
soft/gpx.c

@@ -46,6 +46,25 @@ struct kalman_s {
 #define POS_JUMP_THRESHOLD	100.0	/* meters between consecutive raw fixes */
 #define ALT_JUMP_THRESHOLD	50.0	/* meters between consecutive raw fixes */
 
+/* Post-gap settling: a freshly reacquired fix's position/altitude solution
+ * can keep drifting for several seconds after reacquisition even while
+ * reported quality (satellite count, HDOP) already looks good throughout -
+ * seen on a real recorded track exiting an underground space: HDOP was
+ * already 0.81/17 sats just 7s after reacquiring, yet elevation kept
+ * changing by several meters per sample for another ~10s after that before
+ * settling to normal (sub-meter) noise. Sat count/HDOP were tried as a
+ * "has it converged" signal and rejected: both fluctuate (sat count can
+ * drop as easily as rise while the receiver's tracking set changes) in a
+ * way that gave false "stable" readings well before the position had
+ * actually settled. A fixed wall-clock window matches what the real data
+ * showed converging by, without depending on a quality signal that proved
+ * unreliable for this specific purpose in practice.
+ * Withhold distance/elevation/GPX credit for points within this window
+ * after a gap, while still updating the Kalman/last-position state normally
+ * so a genuine tunnel-with-real-displacement case isn't penalized beyond
+ * losing GAP_SETTLE_SECONDS worth of distance right at the exit. */
+#define GAP_SETTLE_SECONDS	18	/* real data showed convergence taking ~15-17s after reacquisition */
+
 struct prev_points_s {
 	struct location_s data[PREV_POINTS_LENGTH];
 	unsigned char start;
@@ -75,6 +94,7 @@ static struct gpx_s {
 	struct location_s last_distance_point; /* Last accepted point for distance calculation */
 	struct kalman_s kalman[2];
 	struct elevation_s elevation;
+	time_t gap_settle_until; /* points before this time are held back post-gap; 0 = not settling */
 } gpx;
 
 float kalman_predict(struct kalman_s *k, float data);
@@ -112,10 +132,11 @@ unsigned char gpx_init(FIL *file) {
 	gpx.last_saved.lat = 0;
 	gpx.last_saved.time = 0;
 	gpx.last_distance_point.lon = 0;
-	gpx.last_distance_point.lat = 0;
+	gpx.last_distance_point.lat = NAN; /* sentinel: no prior point yet, distinct from a real lat=0 (equator) fix */
 	gpx.last_distance_point.time = 0;
 	gpx.elevation.initialized = 0;
 	gpx.point_count = 0;
+	gpx.gap_settle_until = 0;
 
 	gpx.paused = 1; /* make it add a <trkseg> tag */
 
@@ -216,6 +237,22 @@ void gpx_reset_gap(void) {
 	gpx.avg_store.lon = 0;
 	gpx.avg_store.time = 0;
 	gpx.point_count = 0;
+	/* Without this, the first point(s) accepted after the gap (still inside
+	 * the prev_points warm-up above, so exempt from the distdiff/altdiff
+	 * spike checks until that window refills) would add one uncontrolled
+	 * distance/elevation segment straight from the pre-gap position - which
+	 * may be stale by an arbitrary, unknown amount of real movement during
+	 * the gap - with none of the usual spike protection. Clearing it forces
+	 * distance/elevation accumulation to (re)start fresh from the first
+	 * post-gap point actually reached once warm-up completes, the same way
+	 * it starts fresh after power-on. */
+	gpx.last_distance_point.lat = NAN;
+	/* -1 (all-ones): "settling, but the deadline isn't computed yet" - the
+	 * real deadline needs the first post-gap point's own timestamp, which
+	 * isn't available here (this runs before gpx_process_point() sees that
+	 * point), so gpx_process_point() computes gap_settle_until = that
+	 * point's time + GAP_SETTLE_SECONDS on its first point after this. */
+	gpx.gap_settle_until = (time_t)-1;
 }
 
 void gpx_process_point(struct location_s *loc, FIL *file){
@@ -233,6 +270,18 @@ void gpx_process_point(struct location_s *loc, FIL *file){
 		return;
 	}
 
+	if (gpx.gap_settle_until) {
+		if (gpx.gap_settle_until == (time_t)-1)
+			gpx.gap_settle_until = loc->time + GAP_SETTLE_SECONDS;
+		if (loc->time < gpx.gap_settle_until) {
+			if (get_flag(CONFFLAG_VERBOSE_LOG))
+				xputs_P(PSTR("GAP SETTLE: waiting for fix to stabilize\r\n"));
+			System.points_skipped++;
+			return;
+		}
+		gpx.gap_settle_until = 0; /* window elapsed - resume normal processing below */
+	}
+
 	/* Always apply Kalman filtering for distance calculation */
 	lat_est = kalman_predict(&gpx.kalman[0], loc->lat);
 	lon_est = kalman_predict(&gpx.kalman[1], loc->lon);
@@ -252,7 +301,7 @@ void gpx_process_point(struct location_s *loc, FIL *file){
 		System.points_accepted++;
 
 		/* Calculate distance and elevation from filtered points */
-		if (gpx.last_distance_point.lat != 0) {
+		if (!isnan(gpx.last_distance_point.lat)) {
 			float ele_change;
 			dist = distance(&gpx.last_distance_point, &filtered_loc);
 			if (dist > POS_JUMP_THRESHOLD)
@@ -267,9 +316,16 @@ void gpx_process_point(struct location_s *loc, FIL *file){
 		gpx.last_distance_point = filtered_loc;
 
 	} else {
-		/* Apply Kalman error check */
+		/* Apply Kalman error check. KALMAN_ERR_MAX is a threshold in degrees
+		 * of latitude - a degree of longitude covers less ground the further
+		 * from the equator, by a factor of cos(latitude), so lon_err is
+		 * scaled up here to the equivalent latitude-degree error before the
+		 * same threshold is applied to both. Without this, the effective
+		 * east-west tolerance in meters would shrink at higher latitudes
+		 * (e.g. roughly half as permissive at 60 degrees as at the equator),
+		 * causing spurious rejects that have nothing to do with fix quality. */
 		lat_err = fabs(loc->lat - lat_est);
-		lon_err = fabs(loc->lon - lon_est);
+		lon_err = fabs(loc->lon - lon_est) * cosf(loc->lat * M_PI / 180.0);
 	//	xprintf(PSTR("lat_err: %e, lon_err: %e, limit: %e\r\n"), lat_err, lon_err, (float)KALMAN_ERR_MAX);
 		if(lat_err > KALMAN_ERR_MAX || lon_err > KALMAN_ERR_MAX){
 			if (get_flag(CONFFLAG_VERBOSE_LOG))
@@ -299,13 +355,26 @@ void gpx_process_point(struct location_s *loc, FIL *file){
 			float alt32 = fabs(prev_points_get(2)->alt - prev_points_get(1)->alt);
 			if (get_flag(CONFFLAG_VERBOSE_LOG))
 				xprintf(PSTR("New distance: %fm\r\n"), dist32);
-			if(dist34 > dist12 && dist32 > dist12){
+			/* A real spike must stand out on BOTH sides - bigger than the
+			 * older segment AND bigger than the newer one. The previous
+			 * "dist34 > dist12 && dist32 > dist12" shape only checked dist32
+			 * against dist12 twice from different angles, so it stayed true
+			 * for every step of a sustained, monotonic acceleration (dist12 <
+			 * dist32 < dist34, completely normal e.g. when picking up pace
+			 * while hiking) - rejecting real point after real point until the
+			 * trend broke, then crediting all of that accumulated real
+			 * distance to whichever single point finally got through, as one
+			 * artificially fast-looking GPX segment. Comparing dist32 against
+			 * both neighbors instead lets a genuine acceleration/deceleration
+			 * ramp through cleanly while still catching an isolated outlier
+			 * surrounded by two much smaller segments. */
+			if(dist32 > dist12 && dist32 > dist34){
 				if (get_flag(CONFFLAG_VERBOSE_LOG))
 					xputs_P(PSTR("DISTANCE DIFF REJECT\r\n"));
 				log_reject(REJECT_REASON_DISTDIFF);
 				return;
 			}
-			if(alt32 > ALT_JUMP_THRESHOLD && alt34 > alt12 && alt32 > alt12){
+			if(alt32 > ALT_JUMP_THRESHOLD && alt32 > alt12 && alt32 > alt34){
 				if (get_flag(CONFFLAG_VERBOSE_LOG))
 					xputs_P(PSTR("ALTITUDE DIFF REJECT\r\n"));
 				log_reject(REJECT_REASON_ALTDIFF);
@@ -337,7 +406,7 @@ void gpx_process_point(struct location_s *loc, FIL *file){
 		System.points_accepted++;
 
 		/* Calculate distance and elevation for accepted point */
-		if (gpx.last_distance_point.lat != 0) {
+		if (!isnan(gpx.last_distance_point.lat)) {
 			float ele_change;
 			dist = distance(&gpx.last_distance_point, ptr);
 			add_distance(dist);
@@ -351,8 +420,16 @@ void gpx_process_point(struct location_s *loc, FIL *file){
 
 		gpx.avg_store.lat += ptr->lat;
 		gpx.avg_store.lon += ptr->lon;
-		if(gpx.avg_count == AVG_COUNT/2)
-			gpx.avg_store.time = ptr->time;
+		/* Use the newest accepted point's time, not e.g. the midpoint's: the
+		 * averaged lat/lon reflects real movement up through this instant, so
+		 * timestamping it any earlier claims that movement happened faster
+		 * than it did. This matters most exactly when the accepted points
+		 * feeding this average were NOT evenly spaced in real time (e.g. a
+		 * reject streak between two of them) - an earlier timestamp there
+		 * would understate the elapsed time for the position delta and
+		 * inflate the speed an external tool derives from consecutive GPX
+		 * points. */
+		gpx.avg_store.time = ptr->time;
 
 		if(++gpx.avg_count == AVG_COUNT){
 			nloc.lat = gpx.avg_store.lat / AVG_COUNT;