Upload files to "PTPLogger"
This commit is contained in:
Binary file not shown.
+800
-59
@@ -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:
|
||||
timestr = ts_match.group(1) + " 2026"
|
||||
last_timestamp = dt.datetime.strptime(timestr, "%b %d %H:%M:%S %Y")
|
||||
except:
|
||||
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
|
||||
|
||||
m = re.search(r'rms\s+(\d+).*freq\s+([+-]?\d+)', line)
|
||||
# All the data we care about (rms/freq/delay, port state, master
|
||||
# clock selection, link up/down) is emitted by a ptp4l@<interface>
|
||||
# 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:
|
||||
t.append(timestamp)
|
||||
rms.append(int(m.group(1)))
|
||||
freq.append(int(m.group(2)))
|
||||
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:
|
||||
state_events.append((timestamp, f"{m.group(1)}→{m.group(2)}"))
|
||||
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:
|
||||
master_events.append((timestamp, m.group(2)))
|
||||
bucket["master_events"].append((timestamp, m.group(2)))
|
||||
|
||||
return t, rms, freq, state_events, master_events
|
||||
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("<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
|
||||
|
||||
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("<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
|
||||
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)
|
||||
|
||||
threading.Thread(target=check_for_updates, daemon=True).start()
|
||||
|
||||
root.mainloop()
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.0
|
||||
1.1.3
|
||||
@@ -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])])
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user