diff --git a/PTPLogger/PTPLogGrapher.exe b/PTPLogger/PTPLogGrapher.exe index af330d1..3edabd6 100644 Binary files a/PTPLogger/PTPLogGrapher.exe and b/PTPLogger/PTPLogGrapher.exe differ diff --git a/PTPLogger/PTPLogger_Graphing.py b/PTPLogger/PTPLogger_Graphing.py index d1e9636..b3f0694 100644 --- a/PTPLogger/PTPLogger_Graphing.py +++ b/PTPLogger/PTPLogger_Graphing.py @@ -1,63 +1,424 @@ import re +import os +import io +import sys import datetime as dt +import threading +import urllib.request +import webbrowser import matplotlib.pyplot as plt import matplotlib.dates as mdates +import matplotlib.font_manager as fm from matplotlib.lines import Line2D +from matplotlib.ticker import FuncFormatter + + +def resource_path(relative_path): + """Resolve a path to a bundled resource (e.g. a font file), working + both when run as a plain script and when built into a PyInstaller exe. + A --onefile build extracts data files to a temp dir at sys._MEIPASS; + everything else just uses the folder this script lives in.""" + base_path = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) + return os.path.join(base_path, relative_path) + + +# Bundle Open Sans (Regular + a Bold instance derived from the variable +# font) so the graphs render consistently even on machines that don't have +# it installed system-wide. If the font files aren't found alongside the +# script/exe for any reason, this falls back to matplotlib's default font +# rather than erroring. +for _font_file in ("OpenSans-VariableFont_wdth_wght.ttf", "OpenSans-Bold.ttf"): + _font_path = resource_path(os.path.join("fonts", _font_file)) + if os.path.exists(_font_path): + try: + fm.fontManager.addfont(_font_path) + except Exception: + pass + +plt.rcParams['font.family'] = ['Open Sans', 'DejaVu Sans', 'sans-serif'] import tkinter as tk -from tkinter import filedialog, messagebox, scrolledtext +from tkinter import filedialog, messagebox, scrolledtext, ttk +from tkcalendar import DateEntry +import platform +import subprocess +import shutil +import tempfile + +# Clipboard image support is implemented differently per OS — there's no +# single cross-platform way to put an image (not just text) on the system +# clipboard. Windows uses pywin32 (a real dependency); Linux uses whichever +# of xclip/wl-copy is already on the system (no extra Python package +# needed); macOS uses the built-in osascript (likewise no extra package). +_PLATFORM = platform.system() # 'Windows', 'Linux', 'Darwin', ... + +if _PLATFORM == "Windows": + try: + import win32clipboard + from PIL import Image + CLIPBOARD_SUPPORTED = True + except ImportError: + CLIPBOARD_SUPPORTED = False +else: + # Actual tool availability (xclip/wl-copy/osascript) is checked at + # copy-time instead, since it depends on what's installed, not on a + # Python package. + CLIPBOARD_SUPPORTED = True + +# Format used to render the overall range in the legend: DD-MMMM HH:MM:SS +RANGE_FORMAT = "%d-%B %H:%M:%S" + +# Sanity ceilings for a single rms/max/delay/freq sample. A real PTP fault +# can genuinely produce offsets in the tens of milliseconds right after a +# master switchover or a bad transient, so the bar here is set well above +# that (1 full second) — it's only meant to catch ptp4l's known stats- +# overflow bug, where a sample prints as something like 1e16-1e18 ns, +# which is many orders of magnitude beyond any real-world value. +# +# freq is similarly generous: a device in extended holdover (no master, +# free-running) can legitimately drift into the millions of ppb the longer +# it stays unsynced — that's real and worth seeing, not corruption. This is +# only meant to catch the same class of stats-overflow garbage as above. +ANOMALY_THRESHOLD_NS = 1_000_000_000 # 1 second, for rms/max/delay +ANOMALY_THRESHOLD_FREQ = 1_000_000_000 # ppb + +# --- Version check --- +# Bump this on every build, and keep PTPLogger/VERSION in the repo matching it. +__version__ = "1.1.3" + +VERSION_URL = "https://gitea.apointless.space/bsncubed/RiedelScripts/raw/branch/main/PTPLogger/VERSION" +DOWNLOAD_PAGE_URL = "https://gitea.apointless.space/bsncubed/RiedelScripts/src/branch/main/PTPLogger" def parse_log(file_path): - t, rms, freq = [], [], [] - state_events = [] - master_events = [] + """Parse a log file, bucketing RMS/freq/delay/max-offset data and events + by which ptp4l interface (media1, media2, ...) emitted them. Returns: + (data, device_name) where data is: + { "media1": {"t":[...], "rms":[...], "freq":[...], "delay":[...], + "max_offset":[...], "state_events":[...], + "master_events":[...], "link_events":[...]}, ... } + and device_name is the syslog hostname (e.g. "Riedel-RSP-1216HL-16-B1-C6"), + or None if it couldn't be found. Interfaces with no matching ptp4l data + simply won't appear in data. + """ + data = {} + device_name = None + + def bucket_for(iface): + if iface not in data: + data[iface] = { + "t": [], "rms": [], "freq": [], "delay": [], "max_offset": [], + "state_events": [], "master_events": [], "link_events": [], + "anomalies": [], + } + return data[iface] + + ts_re_syslog = re.compile(r'^(\w+)\s+(\d+)\s+(\d+:\d+:\d+)') + ts_re_iso = re.compile(r'^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(?:\.\d+)?') + + with open(file_path, encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + + # Syslog-style lines ("Jun 12 06:08:09 ...") carry no year. The file's + # own modification time is the best anchor we have for "now", and it + # corresponds to the LAST line written, not the first — a log can't + # contain future entries, so we have to assign years working backward + # from the end of the file. Walking in reverse, the year only ever + # steps down, and only when the month appears to jump forward (e.g. + # Jan -> Dec), which means we've crossed a New Year's boundary going + # backward in time. ISO-style lines ("2026-05-22 23:21:45...") already + # carry their own year and don't need any of this. + try: + anchor_year = dt.datetime.fromtimestamp(os.path.getmtime(file_path)).year + except OSError: + anchor_year = dt.datetime.now().year + + years_by_index = [None] * len(lines) + current_year = anchor_year + last_month = None + + for i in range(len(lines) - 1, -1, -1): + ts_match = ts_re_syslog.match(lines[i]) + if not ts_match: + continue + try: + month_num = dt.datetime.strptime(ts_match.group(1), "%b").month + except ValueError: + continue + if last_month is not None and month_num > last_month: + current_year -= 1 + last_month = month_num + years_by_index[i] = current_year last_timestamp = None - with open(file_path, encoding='utf-8', errors='ignore') as f: - for line in f: + rms_pattern = re.compile( + r'rms\s+(\d+)\s+max\s+(\d+)\s+freq\s+([+-]?\d+)\s+\+/-\s+\d+\s+delay\s+(\d+)\s+\+/-\s+\d+' + ) - ts_match = re.match(r'^(\w+ \d+ \d+:\d+:\d+)', line) - if ts_match: + for i, line in enumerate(lines): + + timestamp_here = None + remainder = None + + iso_match = ts_re_iso.match(line) + if iso_match: + try: + timestamp_here = dt.datetime.strptime( + f"{iso_match.group(1)} {iso_match.group(2)}", "%Y-%m-%d %H:%M:%S" + ) + remainder = line[iso_match.end():] + except ValueError: + pass + + if timestamp_here is None: + syslog_match = ts_re_syslog.match(line) + if syslog_match and years_by_index[i] is not None: + month_str, day_str, time_str = syslog_match.groups() try: - timestr = ts_match.group(1) + " 2026" - last_timestamp = dt.datetime.strptime(timestr, "%b %d %H:%M:%S %Y") - except: + timestr = f"{month_str} {day_str} {time_str} {years_by_index[i]}" + timestamp_here = dt.datetime.strptime(timestr, "%b %d %H:%M:%S %Y") + remainder = line[syslog_match.end():] + except ValueError: pass - timestamp = last_timestamp + if timestamp_here is not None: + last_timestamp = timestamp_here + if device_name is None and remainder: + stripped = remainder.strip() + if stripped: + device_name = stripped.split(None, 1)[0] - if not timestamp: - continue + timestamp = last_timestamp - m = re.search(r'rms\s+(\d+).*freq\s+([+-]?\d+)', line) - if m: - t.append(timestamp) - rms.append(int(m.group(1))) - freq.append(int(m.group(2))) + if not timestamp: + continue - m = re.search(r'port \d+: (\w+) to (\w+)', line) - if m: - state_events.append((timestamp, f"{m.group(1)}→{m.group(2)}")) + # All the data we care about (rms/freq/delay, port state, master + # clock selection, link up/down) is emitted by a ptp4l@ + # process — different device families wrap the pid differently + # (e.g. "ptp4l@media1[1034]:" vs "[ptp4l@media1(1034)]"), so we + # only key off the interface name itself, not what follows it. + iface_match = re.search(r'ptp4l@(media\d+)', line) + if not iface_match: + continue + bucket = bucket_for(iface_match.group(1)) - m = re.search( - r'(selected best master clock|new foreign master)\s+([0-9a-fA-F\.:]+)', - line - ) - if m: - master_events.append((timestamp, m.group(2))) + m = rms_pattern.search(line) + if m: + rms_val = int(m.group(1)) + max_val = int(m.group(2)) + freq_val = int(m.group(3)) + delay_val = int(m.group(4)) - return t, rms, freq, state_events, master_events + # ptp4l has a known bug class where its internal stats + # accumulator can overflow and print a nonsense huge integer + # for a single sample (we've seen rms/max readings in the + # 1e16-1e18 range against a normal scale of tens-to-thousands + # of nanoseconds). One bad sample like that blows out the whole + # graph's y-axis, so it gets pulled out and flagged instead of + # plotted. + # + # Disabled for now — the freq threshold in particular kept + # catching real (if extreme) holdover drift, not just + # corruption. Left in place commented out in case it's worth + # revisiting with a better threshold later. + # if (abs(rms_val) > ANOMALY_THRESHOLD_NS or abs(max_val) > ANOMALY_THRESHOLD_NS + # or abs(delay_val) > ANOMALY_THRESHOLD_NS or abs(freq_val) > ANOMALY_THRESHOLD_FREQ): + # bucket["anomalies"].append(( + # timestamp, + # f"rms={rms_val} max={max_val} freq={freq_val} delay={delay_val} " + # f"(excluded — implausible value)" + # )) + # else: + bucket["t"].append(timestamp) + bucket["rms"].append(rms_val) + bucket["max_offset"].append(max_val) + bucket["freq"].append(freq_val) + bucket["delay"].append(delay_val) + + m = re.search(r'port \d+: link (up|down)', line) + if m: + bucket["link_events"].append((timestamp, m.group(1))) + + m = re.search(r'port \d+: (\w+) to (\w+)', line) + if m: + bucket["state_events"].append((timestamp, f"{m.group(1)}→{m.group(2)}")) + + m = re.search( + r'(selected best master clock|new foreign master)\s+([0-9a-fA-F\.:]+)', + line + ) + if m: + bucket["master_events"].append((timestamp, m.group(2))) + + return data, device_name -def plot_data(t, rms, freq, state_events, master_events, detail): +def filter_by_range(t, rms, freq, delay, max_offset, state_events, master_events, link_events, + anomalies, start, end): + """Trim parsed data down to [start, end] inclusive, applied before plotting.""" + ft, frms, ffreq, fdelay, fmax = [], [], [], [], [] + for ts, r, fr, d, mx in zip(t, rms, freq, delay, max_offset): + if start <= ts <= end: + ft.append(ts) + frms.append(r) + ffreq.append(fr) + fdelay.append(d) + fmax.append(mx) - if not t: - messagebox.showerror("Error", "No valid RMS/freq data found.") + fstate = [(ts, label) for ts, label in state_events if start <= ts <= end] + fmaster = [(ts, label) for ts, label in master_events if start <= ts <= end] + flink = [(ts, label) for ts, label in link_events if start <= ts <= end] + fanomaly = [(ts, label) for ts, label in anomalies if start <= ts <= end] + + return ft, frms, ffreq, fdelay, fmax, fstate, fmaster, flink, fanomaly + + +def overall_bounds(t, state_events, master_events, link_events=None, anomalies=None): + """Earliest/latest timestamp across RMS data and all event lists.""" + all_ts = list(t) + [ts for ts, _ in state_events] + [ts for ts, _ in master_events] + if link_events: + all_ts += [ts for ts, _ in link_events] + if anomalies: + all_ts += [ts for ts, _ in anomalies] + if not all_ts: + return None, None + return min(all_ts), max(all_ts) + + +def pretty_iface(iface): + m = re.match(r'([a-zA-Z]+)(\d+)', iface) + if m: + return f"{m.group(1).capitalize()} {m.group(2)}" + return iface + + +def _padded_range(lo, hi): + """Turn a min/max into axis limits with a 5% margin (or +/-1 if flat).""" + if lo == hi: + return lo - 1, hi + 1 + margin = (hi - lo) * 0.05 + return lo - margin, hi + margin + + +def _human_readable_tick(value, pos=None): + """Format an axis tick with K/M/B/T/P suffixes instead of matplotlib's + default '1e16'-style scientific offset notation, which is more work to + read at a glance, especially when an extreme outlier sample forces the + whole axis into scientific notation.""" + sign = '-' if value < 0 else '' + abs_val = abs(value) + for threshold, suffix in ((1e15, 'P'), (1e12, 'T'), (1e9, 'B'), (1e6, 'M'), (1e3, 'K')): + if abs_val >= threshold: + return f"{sign}{abs_val / threshold:.1f}{suffix}" + if abs_val == int(abs_val): + return f"{sign}{int(abs_val)}" + return f"{sign}{abs_val:.1f}" + + +def _make_adaptive_date_formatter(ax, zoomed_threshold_seconds=600): + """Unlike matplotlib's ConciseDateFormatter, this always prints the + full day-month hour:minute(:second) string on every tick rather than + abbreviating repeated context across ticks. It switches to including + seconds once the visible window is zoomed in tight enough (by default, + 10 minutes or less) that individual seconds actually matter, and keeps + re-evaluating live as the user zooms/pans.""" + def _formatter(x, pos=None): + vmin, vmax = ax.get_xlim() + span_seconds = (vmax - vmin) * 86400 # matplotlib date units are days + fmt = '%d-%b %H:%M:%S' if span_seconds <= zoomed_threshold_seconds else '%d-%b %H:%M' + return mdates.num2date(x).strftime(fmt) + return FuncFormatter(_formatter) + + +class Tooltip: + """Minimal hover tooltip for a Tkinter widget — plain tk, no extra dependency.""" + def __init__(self, widget, text): + self.widget = widget + self.text = text + self.tip_window = None + widget.bind("", self._show) + widget.bind("", self._hide) + + def _show(self, event=None): + if self.tip_window or not self.text: + return + x = self.widget.winfo_rootx() + 10 + y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5 + self.tip_window = tw = tk.Toplevel(self.widget) + tw.wm_overrideredirect(True) + tw.wm_geometry(f"+{x}+{y}") + tk.Label( + tw, text=self.text, justify=tk.LEFT, wraplength=250, + background="#ffffe0", relief=tk.SOLID, borderwidth=1, font=("", 8) + ).pack(ipadx=4, ipady=2) + + def _hide(self, event=None): + if self.tip_window: + self.tip_window.destroy() + self.tip_window = None + + +def copy_figure_to_clipboard(fig): + if not CLIPBOARD_SUPPORTED: + # Only reachable on Windows without pywin32/pillow installed. + messagebox.showerror( + "Error", + "Copying the graph needs two extra packages.\n\n" + "Run: pip install pywin32 pillow" + ) return - fig, (ax1, ax2) = plt.subplots(2, 1, sharex=True) + buf = io.BytesIO() + fig.savefig(buf, format='png', dpi=150, bbox_inches='tight') + png_bytes = buf.getvalue() + + try: + if _PLATFORM == "Windows": + from PIL import Image + image = Image.open(io.BytesIO(png_bytes)).convert("RGB") + out = io.BytesIO() + image.save(out, "BMP") + data = out.getvalue()[14:] # strip BMP file header, clipboard wants raw DIB + out.close() + win32clipboard.OpenClipboard() + win32clipboard.EmptyClipboard() + win32clipboard.SetClipboardData(win32clipboard.CF_DIB, data) + win32clipboard.CloseClipboard() + + elif _PLATFORM == "Darwin": + if not shutil.which("osascript"): + messagebox.showerror("Error", "Copying the graph needs 'osascript', which should ship with macOS.") + return + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp.write(png_bytes) + tmp_path = tmp.name + try: + script = f'set the clipboard to (read (POSIX file "{tmp_path}") as «class PNGf»)' + subprocess.run(["osascript", "-e", script], check=True) + finally: + os.unlink(tmp_path) + + else: + # Linux's button is disabled before this can ever be reached; + # this is just a safety net in case that assumption ever breaks. + messagebox.showerror("Error", f"Copy Graph isn't supported on this platform ({_PLATFORM}).") + + except Exception as e: + messagebox.showerror("Error", f"Couldn't copy graph: {e}") + + +def plot_data(t, rms, freq, delay, max_offset, state_events, master_events, link_events, + detail, title="PTP Log", rms_ylim=None, freq_ylim=None, delay_ylim=None, max_ylim=None, + custom_title="", device_name=None): + + if not t: + messagebox.showerror("Error", f"No valid RMS/freq data found for {title} in this range.") + return None + + fig, (ax1, ax2, ax3, ax4) = plt.subplots(4, 1, sharex=True, figsize=(10, 9)) + fig.canvas.manager.set_window_title(title) ax1.plot(t, rms, color='black') ax1.set_ylabel("RMS (ns)") @@ -65,31 +426,61 @@ def plot_data(t, rms, freq, state_events, master_events, detail): ax2.plot(t, freq, color='black') ax2.set_ylabel("Frequency deviation") - ax2.set_xlabel("Time") ax2.grid() - # event lines + ax3.plot(t, delay, color='black') + ax3.set_ylabel("Path delay (ns)") + ax3.grid() + + ax4.plot(t, max_offset, color='black') + ax4.set_ylabel("Max offset (ns)") + ax4.set_xlabel("Time") + ax4.grid() + + if rms_ylim is not None: + ax1.set_ylim(*rms_ylim) + if freq_ylim is not None: + ax2.set_ylim(*freq_ylim) + if delay_ylim is not None: + ax3.set_ylim(*delay_ylim) + if max_ylim is not None: + ax4.set_ylim(*max_ylim) + + all_axes = (ax1, ax2, ax3, ax4) + + for ax in all_axes: + ax.yaxis.set_major_formatter(FuncFormatter(_human_readable_tick)) + + # event lines (state_events/master_events/link_events arrive already + # filtered — an empty list here means that category was switched off or + # there's simply nothing in range, either way nothing gets drawn) for ts, _ in state_events: - ax1.axvline(ts, color='blue', linestyle='--', alpha=0.7) - ax2.axvline(ts, color='blue', linestyle='--', alpha=0.7) + for ax in all_axes: + ax.axvline(ts, color='blue', linestyle='--', alpha=0.7) for ts, _ in master_events: - ax1.axvline(ts, color='red', linestyle='--', alpha=0.7) - ax2.axvline(ts, color='red', linestyle='--', alpha=0.7) + for ax in all_axes: + ax.axvline(ts, color='red', linestyle='--', alpha=0.7) + + for ts, _ in link_events: + for ax in all_axes: + ax.axvline(ts, color='green', linestyle='--', alpha=0.7) + + # --- Legend: overall range only, no per-event date breakdown --- + start, end = overall_bounds(t, state_events, master_events, link_events) + range_str = f"{start.strftime(RANGE_FORMAT)} \u2192 {end.strftime(RANGE_FORMAT)}" legend = [ - Line2D([0], [0], color='black', label='RMS/Freq'), - Line2D([0], [0], color='blue', linestyle='--', label='State'), - Line2D([0], [0], color='red', linestyle='--', label='Master'), + Line2D([0], [0], color='black', label=f"RMS/Freq/Delay\n{range_str}"), ] - ax1.legend(handles=legend) + if state_events: + legend.append(Line2D([0], [0], color='blue', linestyle='--', label="State Event")) + if master_events: + legend.append(Line2D([0], [0], color='red', linestyle='--', label="Master Event")) + if link_events: + legend.append(Line2D([0], [0], color='green', linestyle='--', label="Link Event")) - multi_day = any(d.date() != t[0].date() for d in t) - - if multi_day: - formatter = mdates.DateFormatter('%d %H:%M') - else: - formatter = mdates.DateFormatter('%H:%M:%S') + ax1.legend(handles=legend, fontsize=8, loc='upper right') if detail == "Seconds": locator = mdates.SecondLocator(interval=1) @@ -104,14 +495,293 @@ def plot_data(t, rms, freq, state_events, master_events, detail): else: locator = mdates.AutoDateLocator(maxticks=20) - ax2.xaxis.set_major_locator(locator) - ax2.xaxis.set_major_formatter(formatter) + ax4.xaxis.set_major_locator(locator) + # Full day-month hour:minute(:second) string on every tick — switches + # to including seconds once zoomed in tight, rather than abbreviating + # repeated context the way matplotlib's ConciseDateFormatter does. + ax4.xaxis.set_major_formatter(_make_adaptive_date_formatter(ax4)) plt.xticks(rotation=45) - plt.tight_layout() + + # Reserve room at the top for the user's custom title + device subtitle + # (if either is set), and a sliver at the bottom for the units legend. + bottom_margin = 0.04 + if custom_title or device_name: + plt.tight_layout(rect=[0, bottom_margin, 1, 0.92]) + if custom_title: + fig.suptitle(custom_title, fontsize=14, fontweight='bold', y=0.985) + if device_name: + fig.text(0.5, 0.945, device_name, ha='center', fontsize=10, color='dimgray') + elif device_name: + fig.suptitle(device_name, fontsize=11, color='dimgray', y=0.985) + else: + plt.tight_layout(rect=[0, bottom_margin, 1, 1]) + + fig.text( + 0.5, 0.005, + "Axis units: K = thousand, M = million, B = billion, T = trillion, P = quadrillion", + ha='center', fontsize=7, color='gray' + ) + + # Add a "Copy Graph" button to this figure's own window (TkAgg backend) + try: + host_window = fig.canvas.manager.window + btn_bar = tk.Frame(host_window) + btn_bar.pack(side=tk.BOTTOM, fill=tk.X) + + if _PLATFORM == "Linux": + copy_btn = tk.Button(btn_bar, text="Copy Graph", state=tk.DISABLED) + copy_btn.pack(side=tk.LEFT, padx=5, pady=2) + Tooltip(copy_btn, "Direct copy doesn't work on Linux — use the Save icon in the toolbar below instead.") + else: + copy_btn = tk.Button(btn_bar, text="Copy Graph", + command=lambda f=fig: copy_figure_to_clipboard(f)) + copy_btn.pack(side=tk.LEFT, padx=5, pady=2) + except Exception: + pass # non-Tk backend or headless — skip silently + + return fig, ax1, ax4 + + +# ---- module-level storage for the full (unfiltered) parsed dataset, +# keyed by interface: full_data["media1"] = {"t":[...], "rms":[...], ...} +full_data = {} + +# Hostname pulled from the log itself (e.g. "Riedel-RSP-1216HL-16-B1-C6"), +# used as the graph subtitle. None until a file's been loaded. +current_device_name = None + +# ---- cross-figure zoom/pan sync ---- +# Each Go click rebuilds this list with one ax2 per open figure (since ax1 +# and ax2 already share an x-axis within a figure via sharex=True). Zooming +# or panning on any one of them mirrors the new x-range to all the others. +_synced_axes = [] +_sync_in_progress = False + + +def close_all_graph_windows(): + """Close every open graph window and drop the now-stale sync state.""" + global _synced_axes + plt.close('all') + _synced_axes = [] + + +def on_main_window_close(): + close_all_graph_windows() + root.destroy() + + +def _on_xlim_changed(changed_ax): + global _sync_in_progress + if _sync_in_progress: + return + _sync_in_progress = True + try: + new_xlim = changed_ax.get_xlim() + for ax in _synced_axes: + if ax is not changed_ax and ax.get_xlim() != new_xlim: + ax.set_xlim(new_xlim) + ax.figure.canvas.draw_idle() + finally: + _sync_in_progress = False + + +def build_picker(parent, label_text): + """A LabelFrame containing a calendar DateEntry plus H:M:S spinboxes.""" + container = tk.LabelFrame(parent, text=label_text, padx=5, pady=5) + + date_entry = DateEntry(container, date_pattern='dd-mm-yyyy', width=10) + date_entry.pack(side=tk.LEFT, padx=(0, 6)) + + hour_var = tk.StringVar(value="00") + minute_var = tk.StringVar(value="00") + second_var = tk.StringVar(value="00") + + tk.Spinbox(container, from_=0, to=23, width=3, format="%02.0f", + textvariable=hour_var, wrap=True).pack(side=tk.LEFT) + tk.Label(container, text=":").pack(side=tk.LEFT) + tk.Spinbox(container, from_=0, to=59, width=3, format="%02.0f", + textvariable=minute_var, wrap=True).pack(side=tk.LEFT) + tk.Label(container, text=":").pack(side=tk.LEFT) + tk.Spinbox(container, from_=0, to=59, width=3, format="%02.0f", + textvariable=second_var, wrap=True).pack(side=tk.LEFT) + + return { + "frame": container, + "date_entry": date_entry, + "hour": hour_var, + "minute": minute_var, + "second": second_var, + } + + +def get_picker_datetime(picker): + d = picker["date_entry"].get_date() + h = int(picker["hour"].get()) + m = int(picker["minute"].get()) + s = int(picker["second"].get()) + return dt.datetime(d.year, d.month, d.day, h, m, s) + + +def set_picker_datetime(picker, value): + picker["date_entry"].set_date(value.date()) + picker["hour"].set(f"{value.hour:02d}") + picker["minute"].set(f"{value.minute:02d}") + picker["second"].set(f"{value.second:02d}") + + +def populate_event_box(state_events, master_events, link_events, anomalies): + """All four args are lists of (timestamp, interface, label).""" + event_box.delete(1.0, tk.END) + + events = [] + for ts, iface, label in state_events: + events.append((ts, f"[{iface}] {ts} | STATE {label}")) + for ts, iface, label in master_events: + events.append((ts, f"[{iface}] {ts} | MASTER {label}")) + for ts, iface, label in link_events: + events.append((ts, f"[{iface}] {ts} | LINK {label}")) + for ts, iface, label in anomalies: + events.append((ts, f"[{iface}] {ts} | ANOMALY {label}")) + events.sort(key=lambda x: x[0]) + + if events: + for _, line in events: + event_box.insert(tk.END, line + "\n") + else: + event_box.insert(tk.END, "No events found in this range.\n") + + +def generate_graph(): + if not full_data: + messagebox.showerror("Error", "Open a log file first.") + return + + try: + start = get_picker_datetime(from_picker) + end = get_picker_datetime(to_picker) + except (ValueError, TypeError): + messagebox.showerror("Error", "Invalid date/time in the From/To picker.") + return + + if start > end: + messagebox.showerror("Error", "'From' must be before 'To'.") + return + + detail = detail_var.get() + show_state = show_state_var.get() + show_master = show_master_var.get() + show_link = show_link_var.get() + + # Filter every interface first so we can derive a shared y-axis scale + # before plotting any of them — that's what makes media1 vs media2 + # actually comparable instead of each auto-scaling to its own range. + filtered = {} + for iface in sorted(full_data.keys()): + bucket = full_data[iface] + ft, frms, ffreq, fdelay, fmax, fstate, fmaster, flink, fanomaly = filter_by_range( + bucket["t"], bucket["rms"], bucket["freq"], bucket["delay"], bucket["max_offset"], + bucket["state_events"], bucket["master_events"], bucket["link_events"], + bucket["anomalies"], start, end + ) + if not show_state: + fstate = [] + if not show_master: + fmaster = [] + if not show_link: + flink = [] + filtered[iface] = (ft, frms, ffreq, fdelay, fmax, fstate, fmaster, flink, fanomaly) + + rms_ylim = freq_ylim = delay_ylim = max_ylim = None + if len(filtered) > 1: + all_rms = [v for (_, frms, _, _, _, _, _, _, _) in filtered.values() for v in frms] + all_freq = [v for (_, _, ffreq, _, _, _, _, _, _) in filtered.values() for v in ffreq] + all_delay = [v for (_, _, _, fdelay, _, _, _, _, _) in filtered.values() for v in fdelay] + all_max = [v for (_, _, _, _, fmax, _, _, _, _) in filtered.values() for v in fmax] + if all_rms and all_freq: + rms_ylim = _padded_range(min(all_rms), max(all_rms)) + freq_ylim = _padded_range(min(all_freq), max(all_freq)) + if all_delay and all_max: + delay_ylim = _padded_range(min(all_delay), max(all_delay)) + max_ylim = _padded_range(min(all_max), max(all_max)) + + all_state_events = [] + all_master_events = [] + all_link_events = [] + all_anomalies = [] + new_synced_axes = [] + + for iface, (ft, frms, ffreq, fdelay, fmax, fstate, fmaster, flink, fanomaly) in filtered.items(): + result = plot_data(ft, frms, ffreq, fdelay, fmax, fstate, fmaster, flink, detail, + title=f"PTP \u2014 {pretty_iface(iface)}", + rms_ylim=rms_ylim, freq_ylim=freq_ylim, + delay_ylim=delay_ylim, max_ylim=max_ylim, + custom_title=title_var.get().strip(), + device_name=current_device_name) + if result is not None: + _, _, ax_bottom = result + new_synced_axes.append(ax_bottom) + all_state_events.extend((ts, iface, label) for ts, label in fstate) + all_master_events.extend((ts, iface, label) for ts, label in fmaster) + all_link_events.extend((ts, iface, label) for ts, label in flink) + all_anomalies.extend((ts, iface, label) for ts, label in fanomaly) + + global _synced_axes + _synced_axes = new_synced_axes + if len(_synced_axes) > 1: + for ax in _synced_axes: + ax.callbacks.connect('xlim_changed', _on_xlim_changed) + + populate_event_box(all_state_events, all_master_events, all_link_events, all_anomalies) plt.show() +def on_parse_complete(data, device_name, error, file_path): + progress_bar.stop() + progress_bar.pack_forget() + open_button.config(state=tk.NORMAL) + go_button.config(state=tk.NORMAL) + + if error: + messagebox.showerror("Error", f"Failed to parse log:\n{error}") + status_label.config(text="") + return + + if not data: + messagebox.showerror("Error", "No valid RMS/freq data or events found.") + status_label.config(text="") + return + + full_data.clear() + full_data.update(data) + + global current_device_name + current_device_name = device_name + + all_starts, all_ends = [], [] + for bucket in full_data.values(): + s, e = overall_bounds(bucket["t"], bucket["state_events"], bucket["master_events"], + bucket["link_events"], bucket["anomalies"]) + if s is not None: + all_starts.append(s) + all_ends.append(e) + + if not all_starts: + messagebox.showerror("Error", "No valid RMS/freq data or events found.") + status_label.config(text="") + return + + start, end = min(all_starts), max(all_ends) + + from_picker["date_entry"].config(mindate=start.date(), maxdate=end.date()) + to_picker["date_entry"].config(mindate=start.date(), maxdate=end.date()) + set_picker_datetime(from_picker, start) + set_picker_datetime(to_picker, end) + + ifaces = ", ".join(pretty_iface(i) for i in sorted(full_data.keys())) + status_label.config(text=f"Loaded {os.path.basename(file_path)} ({ifaces}). Set your range and click Go.") + + def open_file(): file_path = filedialog.askopenfilename( title="Select log", @@ -121,40 +791,72 @@ def open_file(): if not file_path: return - detail = detail_var.get() + # Loading a new file replaces whatever was on screen before + close_all_graph_windows() - t, rms, freq, state_events, master_events = parse_log(file_path) + size_mb = os.path.getsize(file_path) / (1024 * 1024) - plot_data(t, rms, freq, state_events, master_events, detail) + open_button.config(state=tk.DISABLED) + go_button.config(state=tk.DISABLED) + status_label.config(text=f"Parsing {os.path.basename(file_path)} ({size_mb:.1f} MB)...") + progress_bar.pack(side=tk.LEFT, padx=10) + progress_bar.start(10) - event_box.delete(1.0, tk.END) + def worker(): + try: + data, device_name = parse_log(file_path) + error = None + except Exception as e: + data, device_name = None, None + error = str(e) + root.after(0, lambda: on_parse_complete(data, device_name, error, file_path)) - events = [] + threading.Thread(target=worker, daemon=True).start() - for ts, label in state_events: - events.append((ts, f"{ts} | STATE {label}")) - for ts, label in master_events: - events.append((ts, f"{ts} | MASTER {label}")) +def _version_tuple(v): + parts = [] + for p in v.strip().split("."): + try: + parts.append(int(p)) + except ValueError: + parts.append(0) + return tuple(parts) - events.sort(key=lambda x: x[0]) - if events: - for _, line in events: - event_box.insert(tk.END, line + "\n") - else: - event_box.insert(tk.END, "No events found.\n") +def show_update_banner(latest_version): + banner = tk.Label( + root, + text=f"Update available: v{latest_version} (you have v{__version__}) \u2014 click to view", + fg="white", bg="#3b6ea5", cursor="hand2", pady=4 + ) + banner.pack(fill=tk.X, side=tk.TOP, before=frame) + banner.bind("", lambda e: webbrowser.open(DOWNLOAD_PAGE_URL)) + + +def check_for_updates(): + """Runs in a background thread. Fails silently — offline, Gitea down, + or the VERSION file missing should never interrupt the app starting.""" + try: + with urllib.request.urlopen(VERSION_URL, timeout=3) as resp: + latest = resp.read().decode("utf-8").strip() + if _version_tuple(latest) > _version_tuple(__version__): + root.after(0, lambda: show_update_banner(latest)) + except Exception: + pass # GUI root = tk.Tk() root.title("PTP Log Viewer") -root.geometry("750x550") +root.geometry("900x650") +root.protocol("WM_DELETE_WINDOW", on_main_window_close) frame = tk.Frame(root) frame.pack(pady=5) -tk.Button(frame, text="Open Log", command=open_file).pack(side=tk.LEFT, padx=10) +open_button = tk.Button(frame, text="Open Log", command=open_file) +open_button.pack(side=tk.LEFT, padx=10) detail_var = tk.StringVar(value="Auto") @@ -167,9 +869,48 @@ tk.OptionMenu( "10 sec", "30 sec", "1 min" -).pack(side=tk.LEFT) +).pack(side=tk.LEFT, padx=(0, 10)) + +progress_bar = ttk.Progressbar(frame, mode='indeterminate', length=150) +# not packed until a file is loading + +title_frame = tk.Frame(root) +title_frame.pack(pady=(0, 5)) + +tk.Label(title_frame, text="Graph Title:").pack(side=tk.LEFT, padx=(10, 5)) +title_var = tk.StringVar(value="") +tk.Entry(title_frame, textvariable=title_var, width=50).pack(side=tk.LEFT) +tk.Label(title_frame, text="(subtitle is the device name from the log)", fg="gray").pack(side=tk.LEFT, padx=(8, 0)) + +status_label = tk.Label(root, text="", fg="gray") +status_label.pack(pady=(0, 5)) + +range_frame = tk.Frame(root) +range_frame.pack(pady=(0, 5)) + +from_picker = build_picker(range_frame, "From") +from_picker["frame"].pack(side=tk.LEFT, padx=5) + +to_picker = build_picker(range_frame, "To") +to_picker["frame"].pack(side=tk.LEFT, padx=5) + +show_state_var = tk.BooleanVar(value=True) +show_master_var = tk.BooleanVar(value=True) +show_link_var = tk.BooleanVar(value=True) + +tk.Checkbutton(range_frame, text="State events", variable=show_state_var, + fg="blue").pack(side=tk.LEFT, padx=(10, 0)) +tk.Checkbutton(range_frame, text="Master events", variable=show_master_var, + fg="red").pack(side=tk.LEFT, padx=(5, 0)) +tk.Checkbutton(range_frame, text="Link events", variable=show_link_var, + fg="green").pack(side=tk.LEFT, padx=(5, 10)) + +go_button = tk.Button(range_frame, text="Go", command=generate_graph) +go_button.pack(side=tk.LEFT, padx=10) event_box = scrolledtext.ScrolledText(root) event_box.pack(fill=tk.BOTH, expand=True) -root.mainloop() \ No newline at end of file +threading.Thread(target=check_for_updates, daemon=True).start() + +root.mainloop() diff --git a/PTPLogger/VERSION b/PTPLogger/VERSION index afaf360..9c1218c 100644 --- a/PTPLogger/VERSION +++ b/PTPLogger/VERSION @@ -1 +1 @@ -1.0.0 \ No newline at end of file +1.1.3 \ No newline at end of file diff --git a/PTPLogger/version_info.txt b/PTPLogger/version_info.txt index c16a4ff..bd0c0e2 100644 --- a/PTPLogger/version_info.txt +++ b/PTPLogger/version_info.txt @@ -6,8 +6,8 @@ VSVersionInfo( ffi=FixedFileInfo( # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) # Set not needed items to zero 0. - filevers=(1, 1, 2, 0), - prodvers=(1, 1, 2, 0), + filevers=(1, 1, 3, 0), + prodvers=(1, 1, 3, 0), # Contains a bitmask that specifies the valid bits 'flags' mask=0x3f, # Contains a bitmask that specifies the Boolean attributes of the file. @@ -31,12 +31,12 @@ VSVersionInfo( u'040904B0', [StringStruct(u'CompanyName', u'A Pointless Space'), StringStruct(u'FileDescription', u'PTP Log Grapher'), - StringStruct(u'FileVersion', u'1.1.2.0'), + StringStruct(u'FileVersion', u'1.1.3'), StringStruct(u'InternalName', u'PTPLogGrapher'), StringStruct(u'LegalCopyright', u''), StringStruct(u'OriginalFilename', u'PTPLogGrapher.exe'), StringStruct(u'ProductName', u'PTP Log Grapher'), - StringStruct(u'ProductVersion', u'1.1.2')]) + StringStruct(u'ProductVersion', u'1.1.3')]) ]), VarFileInfo([VarStruct(u'Translation', [1033, 1200])]) ]