Upload files to "PTPLogger"

This commit is contained in:
2026-06-17 19:07:21 +10:00
parent 71ab73a3f8
commit b4a7c787b7
4 changed files with 817 additions and 76 deletions
Binary file not shown.
+812 -71
View File
@@ -1,63 +1,424 @@
import re import re
import os
import io
import sys
import datetime as dt import datetime as dt
import threading
import urllib.request
import webbrowser
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import matplotlib.dates as mdates import matplotlib.dates as mdates
import matplotlib.font_manager as fm
from matplotlib.lines import Line2D 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 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): def parse_log(file_path):
t, rms, freq = [], [], [] """Parse a log file, bucketing RMS/freq/delay/max-offset data and events
state_events = [] by which ptp4l interface (media1, media2, ...) emitted them. Returns:
master_events = [] (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 last_timestamp = None
with open(file_path, encoding='utf-8', errors='ignore') as f: rms_pattern = re.compile(
for line in f: 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) for i, line in enumerate(lines):
if ts_match:
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: try:
timestr = ts_match.group(1) + " 2026" timestr = f"{month_str} {day_str} {time_str} {years_by_index[i]}"
last_timestamp = dt.datetime.strptime(timestr, "%b %d %H:%M:%S %Y") timestamp_here = dt.datetime.strptime(timestr, "%b %d %H:%M:%S %Y")
except: remainder = line[syslog_match.end():]
except ValueError:
pass 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: timestamp = last_timestamp
continue
m = re.search(r'rms\s+(\d+).*freq\s+([+-]?\d+)', line) if not timestamp:
if m: continue
t.append(timestamp)
rms.append(int(m.group(1)))
freq.append(int(m.group(2)))
m = re.search(r'port \d+: (\w+) to (\w+)', line) # All the data we care about (rms/freq/delay, port state, master
if m: # clock selection, link up/down) is emitted by a ptp4l@<interface>
state_events.append((timestamp, f"{m.group(1)}{m.group(2)}")) # 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( m = rms_pattern.search(line)
r'(selected best master clock|new foreign master)\s+([0-9a-fA-F\.:]+)', if m:
line rms_val = int(m.group(1))
) max_val = int(m.group(2))
if m: freq_val = int(m.group(3))
master_events.append((timestamp, m.group(2))) 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: fstate = [(ts, label) for ts, label in state_events if start <= ts <= end]
messagebox.showerror("Error", "No valid RMS/freq data found.") 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("<Enter>", self._show)
widget.bind("<Leave>", 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 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.plot(t, rms, color='black')
ax1.set_ylabel("RMS (ns)") 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.plot(t, freq, color='black')
ax2.set_ylabel("Frequency deviation") ax2.set_ylabel("Frequency deviation")
ax2.set_xlabel("Time")
ax2.grid() 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: for ts, _ in state_events:
ax1.axvline(ts, color='blue', linestyle='--', alpha=0.7) for ax in all_axes:
ax2.axvline(ts, color='blue', linestyle='--', alpha=0.7) ax.axvline(ts, color='blue', linestyle='--', alpha=0.7)
for ts, _ in master_events: for ts, _ in master_events:
ax1.axvline(ts, color='red', linestyle='--', alpha=0.7) for ax in all_axes:
ax2.axvline(ts, color='red', linestyle='--', alpha=0.7) 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 = [ legend = [
Line2D([0], [0], color='black', label='RMS/Freq'), Line2D([0], [0], color='black', label=f"RMS/Freq/Delay\n{range_str}"),
Line2D([0], [0], color='blue', linestyle='--', label='State'),
Line2D([0], [0], color='red', linestyle='--', label='Master'),
] ]
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) ax1.legend(handles=legend, fontsize=8, loc='upper right')
if multi_day:
formatter = mdates.DateFormatter('%d %H:%M')
else:
formatter = mdates.DateFormatter('%H:%M:%S')
if detail == "Seconds": if detail == "Seconds":
locator = mdates.SecondLocator(interval=1) locator = mdates.SecondLocator(interval=1)
@@ -104,14 +495,293 @@ def plot_data(t, rms, freq, state_events, master_events, detail):
else: else:
locator = mdates.AutoDateLocator(maxticks=20) locator = mdates.AutoDateLocator(maxticks=20)
ax2.xaxis.set_major_locator(locator) ax4.xaxis.set_major_locator(locator)
ax2.xaxis.set_major_formatter(formatter) # 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.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() 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(): def open_file():
file_path = filedialog.askopenfilename( file_path = filedialog.askopenfilename(
title="Select log", title="Select log",
@@ -121,40 +791,72 @@ def open_file():
if not file_path: if not file_path:
return 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: def _version_tuple(v):
events.append((ts, f"{ts} | MASTER {label}")) 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: def show_update_banner(latest_version):
for _, line in events: banner = tk.Label(
event_box.insert(tk.END, line + "\n") root,
else: text=f"Update available: v{latest_version} (you have v{__version__}) \u2014 click to view",
event_box.insert(tk.END, "No events found.\n") fg="white", bg="#3b6ea5", cursor="hand2", pady=4
)
banner.pack(fill=tk.X, side=tk.TOP, before=frame)
banner.bind("<Button-1>", 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 # GUI
root = tk.Tk() root = tk.Tk()
root.title("PTP Log Viewer") 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 = tk.Frame(root)
frame.pack(pady=5) 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") detail_var = tk.StringVar(value="Auto")
@@ -167,9 +869,48 @@ tk.OptionMenu(
"10 sec", "10 sec",
"30 sec", "30 sec",
"1 min" "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 = scrolledtext.ScrolledText(root)
event_box.pack(fill=tk.BOTH, expand=True) event_box.pack(fill=tk.BOTH, expand=True)
root.mainloop() threading.Thread(target=check_for_updates, daemon=True).start()
root.mainloop()
+1 -1
View File
@@ -1 +1 @@
1.0.0 1.1.3
+4 -4
View File
@@ -6,8 +6,8 @@ VSVersionInfo(
ffi=FixedFileInfo( ffi=FixedFileInfo(
# filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4)
# Set not needed items to zero 0. # Set not needed items to zero 0.
filevers=(1, 1, 2, 0), filevers=(1, 1, 3, 0),
prodvers=(1, 1, 2, 0), prodvers=(1, 1, 3, 0),
# Contains a bitmask that specifies the valid bits 'flags' # Contains a bitmask that specifies the valid bits 'flags'
mask=0x3f, mask=0x3f,
# Contains a bitmask that specifies the Boolean attributes of the file. # Contains a bitmask that specifies the Boolean attributes of the file.
@@ -31,12 +31,12 @@ VSVersionInfo(
u'040904B0', u'040904B0',
[StringStruct(u'CompanyName', u'A Pointless Space'), [StringStruct(u'CompanyName', u'A Pointless Space'),
StringStruct(u'FileDescription', u'PTP Log Grapher'), 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'InternalName', u'PTPLogGrapher'),
StringStruct(u'LegalCopyright', u''), StringStruct(u'LegalCopyright', u''),
StringStruct(u'OriginalFilename', u'PTPLogGrapher.exe'), StringStruct(u'OriginalFilename', u'PTPLogGrapher.exe'),
StringStruct(u'ProductName', u'PTP Log Grapher'), 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])]) VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
] ]