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, 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): """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 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+' ) 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 = 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 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] timestamp = last_timestamp if not timestamp: continue # 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 = 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)) # 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 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) 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 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)") ax1.grid() ax2.plot(t, freq, color='black') ax2.set_ylabel("Frequency deviation") ax2.grid() 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: for ax in all_axes: ax.axvline(ts, color='blue', linestyle='--', alpha=0.7) for ts, _ in master_events: 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=f"RMS/Freq/Delay\n{range_str}"), ] 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")) ax1.legend(handles=legend, fontsize=8, loc='upper right') if detail == "Seconds": locator = mdates.SecondLocator(interval=1) elif detail == "5 sec": locator = mdates.SecondLocator(interval=5) elif detail == "10 sec": locator = mdates.SecondLocator(interval=10) elif detail == "30 sec": locator = mdates.SecondLocator(interval=30) elif detail == "1 min": locator = mdates.MinuteLocator(interval=1) else: locator = mdates.AutoDateLocator(maxticks=20) 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) # 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", filetypes=[("Log files", "*.log *.txt"), ("All files", "*.*")] ) if not file_path: return # Loading a new file replaces whatever was on screen before close_all_graph_windows() size_mb = os.path.getsize(file_path) / (1024 * 1024) 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) 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)) threading.Thread(target=worker, daemon=True).start() def _version_tuple(v): parts = [] for p in v.strip().split("."): try: parts.append(int(p)) except ValueError: parts.append(0) return tuple(parts) 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("900x650") root.protocol("WM_DELETE_WINDOW", on_main_window_close) frame = tk.Frame(root) frame.pack(pady=5) 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") tk.OptionMenu( frame, detail_var, "Auto", "Seconds", "5 sec", "10 sec", "30 sec", "1 min" ).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) threading.Thread(target=check_for_updates, daemon=True).start() root.mainloop()