Exemple #1
0
class PyLatency:
    """Ping tool visualization with tkinter"""
    def __init__(self, root):
        """Setup window geometry & widgets + layout, init counters"""

        self.master = root
        self.master.title("pyLatency")
        self.appdata_dir = getenv("APPDATA") + "/pyLatency"
        self.options_path = self.appdata_dir + "/options.json"
        self.log_dir = self.appdata_dir + "/logs"
        self.logfile = None

        self.options_logging = BooleanVar()
        self.options_geometry = ""

        self.options = self.init_options()
        if self.options:
            self.options_geometry = self.options["geometry"]
            self.options_logging.set(self.options["logging"])

        if self.options_geometry:
            self.master.geometry(self.options_geometry)
        else:
            self.master.geometry("400x200")

        self.master.minsize(width=400, height=200)
        self.master.update()

        self.running = False
        self.hostname = None
        self.RECT_SCALE_FACTOR = 2
        self.TIMEOUT = 5000
        self.minimum = self.TIMEOUT
        self.maximum = 0
        self.average = 0
        self.SAMPLE_SIZE = 1000
        self.sample = deque(maxlen=self.SAMPLE_SIZE)
        self.pcount = 0
        self.max_bar = None
        self.min_bar = None

        # Widgets:
        self.frame = Frame(self.master)

        self.lbl_entry = Label(self.frame, text="Host:")
        self.lbl_status_1 = Label(self.frame, text="Ready")
        self.lbl_status_2 = Label(self.frame, fg="red")
        self.entry = Entry(self.frame)

        self.btn_start = Button(self.frame, text="Start", command=self.start)

        self.btn_stop = Button(self.frame, text="Stop", command=self.stop)

        self.chk_log = Checkbutton(self.frame,
                                   text="Enable log",
                                   variable=self.options_logging)

        self.delay_scale = Scale(
            self.frame,
            label="Interval (ms)",
            orient="horizontal",
            from_=100,
            to=self.TIMEOUT,
            resolution=100,
        )
        self.delay_scale.set(1000)

        self.paneview = PanedWindow(self.master, sashwidth=5, bg="#cccccc")
        self.left_pane = PanedWindow(self.paneview)
        self.right_pane = PanedWindow(self.paneview)
        self.paneview.add(self.left_pane)
        self.paneview.add(self.right_pane)

        self.canvas_scroll_y = Scrollbar(self.left_pane)
        self.canvas = Canvas(self.left_pane,
                             bg="#FFFFFF",
                             yscrollcommand=self.canvas_scroll_y.set)
        self.canvas_scroll_y.config(command=self.canvas.yview)
        self.left_pane.add(self.canvas_scroll_y)

        self.ping_list_scroll = Scrollbar(self.master)
        self.ping_list = Listbox(self.right_pane,
                                 highlightthickness=0,
                                 font=14,
                                 selectmode="disabled",
                                 yscrollcommand=self.ping_list_scroll.set)
        self.ping_list_scroll.config(command=self.ping_list.yview)
        self.right_pane.add(self.ping_list_scroll)

        self.left_pane.add(self.canvas)
        self.right_pane.add(self.ping_list)

        # Layout:
        self.master.columnconfigure(0, weight=1)
        self.master.rowconfigure(1, weight=1)

        self.frame.columnconfigure(1, weight=1)

        self.frame.grid(row=0, column=0, sticky="nsew")

        self.lbl_entry.grid(row=0, column=0)
        self.lbl_status_1.grid(row=1, column=0, columnspan=4)
        self.lbl_status_2.grid(row=2, column=0, columnspan=4)
        self.entry.grid(row=0, column=1, sticky="ew")
        self.btn_start.grid(row=0, column=2)
        self.btn_stop.grid(row=0, column=3)
        self.chk_log.grid(row=1, column=2, columnspan=2)
        self.delay_scale.grid(row=0, column=4, rowspan=2)

        # self.canvas_scroll_y.grid(row=1, column=2, sticky="ns")
        self.paneview.grid(row=1, column=0, sticky="nsew")
        # self.ping_list_scroll.grid(row=1, column=1, sticky="ns")

        self.paneview.paneconfigure(
            self.left_pane,
            width=(self.master.winfo_width() -
                   self.delay_scale.winfo_reqwidth()),
        )

        #Bindings:
        self.canvas.bind("<MouseWheel>", self.scroll_canvas)

        self.master.bind("<Return>", self.start)
        self.master.bind("<Escape>", self.stop)
        self.master.bind("<Control-w>", lambda event: self.master.destroy())
        self.master.bind(
            "<Up>",
            lambda event: self.delay_scale.set(self.delay_scale.get() + 100))
        self.master.bind(
            "<Down>",
            lambda event: self.delay_scale.set(self.delay_scale.get() - 100))

        self.master.protocol("WM_DELETE_WINDOW", self.master_close)

    def __str__(self):
        """Return own address"""

        return f"pyLatency GUI @ {hex(id(self))}"

    def start(self, event=None):
        """
            Reset the GUI, create & start a thread so we don't block
            the mainloop during each poll. 
        """

        if not self.running:
            self.hostname = self.entry.get()
            if self.hostname:
                self.ping_list.delete(0, "end")
                self.canvas.delete("all")
                self.lbl_status_1.config(text="Running", fg="green")
                self.lbl_status_2.config(text="")

                self.sample.clear()

                (self.minimum, self.maximum, self.average,
                 self.pcount) = self.TIMEOUT, 0, 0, 0

                self.running = True
                self.thread = Thread(target=self.run, daemon=True)
                self.thread.start()
            else:
                self.lbl_status_2.config(text="Missing Hostname")

    def logged(fn):
        """
            decorates self.run(), create a log directory if one doesn't
            exist, create a filename with a date & timestamp, call self.run()
            with logging enabled or disabled
        """
        @wraps(fn)
        def inner(self):
            if self.options_logging.get():
                if not exists(self.log_dir):
                    mkdir(self.log_dir)

                timestamp = datetime.now()
                fname = timestamp.strftime("%a %b %d @ %H-%M-%S")

                with open(self.log_dir + f"/{fname}.txt",
                          "w+") as self.logfile:
                    self.logfile.write(f"pyLatency {fname}\n")
                    self.logfile.write(f"Host: {self.hostname}\n")
                    self.logfile.write("-" * 40 + "\n")
                    start = default_timer()
                    fn(self)
                    end = default_timer()
                    elapsed = end - start
                    self.logfile.write("-" * 40 + "\n")
                    self.logfile.write(
                        f"Logged {self.pcount} pings over {int(elapsed)} seconds"
                    )
            else:
                fn(self)

        return inner

    @logged
    def run(self):
        """
            Continuously shell out to ping, get an integer result, 
            update the GUI, and wait. 
        """

        while self.running:
            latency = self.ping(self.hostname)
            self.pcount += 1

            if latency is None:
                self.stop()
                self.lbl_status_2.config(text="Unable to ping host")
                return
            if latency > self.maximum:
                self.maximum = latency
            if latency < self.minimum:
                self.minimum = latency

            self.sample.append(latency)
            self.average = sum(self.sample) / len(self.sample)

            if self.logfile:
                self.logfile.write(str(latency) + "\n")

            self.update_gui(latency)
            sleep(self.delay_scale.get() / 1000)

    def update_gui(self, latency):
        """
            Update the listbox, shift all existing rectangles, draw the latest
            result from self.ping(), cleanup unused rectangles, update the mainloop
        """

        if self.ping_list.size() >= self.SAMPLE_SIZE:
            self.ping_list.delete(self.SAMPLE_SIZE - 1, "end")

        self.ping_list.insert(0, str(latency) + "ms")

        self.canvas.move("rect", 10, 0)
        self.canvas.create_rectangle(0,
                                     0,
                                     10,
                                     int(latency * self.RECT_SCALE_FACTOR),
                                     fill="#333333",
                                     tags="rect",
                                     width=0)

        self.canvas.delete(self.max_bar)
        self.max_bar = self.canvas.create_line(
            0,
            self.maximum * self.RECT_SCALE_FACTOR,
            self.canvas.winfo_width(),
            self.maximum * self.RECT_SCALE_FACTOR,
            fill="red",
        )
        self.canvas.delete(self.min_bar)
        self.min_bar = self.canvas.create_line(
            0,
            self.minimum * self.RECT_SCALE_FACTOR,
            self.canvas.winfo_width(),
            self.minimum * self.RECT_SCALE_FACTOR,
            fill="green",
        )

        # canvas scrollable region is not updated automatically
        self.canvas.configure(scrollregion=self.canvas.bbox("all"))

        self.lbl_status_2.config(fg="#000000",
                                 text=f"Min: {self.minimum} "
                                 f"Max: {self.maximum} "
                                 f"Avg: {round(self.average,2):.2f}")

        self.cleanup_rects()
        self.master.update()

    def scroll_canvas(self, event):
        """
            Bound to <MouseWheel> tkinter event on self.canvas.
            Respond to Linux or Windows mousewheel event, and scroll
            the canvas accordingly
        """

        count = None
        if event.num == 5 or event.delta == -120:
            count = 1
        if event.num == 4 or event.delta == 120:
            count = -1
        self.canvas.yview_scroll(count, "units")

    def cleanup_rects(self):
        """Delete rectangles that are outside the bbox of the canvas"""

        for rect in self.canvas.find_withtag("rect"):
            if self.canvas.coords(rect)[0] > self.canvas.winfo_width():
                self.canvas.delete(rect)

    def stop(self, event=None):
        """Satisfy the condition in which self.thread exits"""

        if self.running:
            self.running = False
            self.lbl_status_1.config(text="Stopped", fg="red")

    def master_close(self, event=None):
        """Writes window geometry/options to the disk."""

        options = dumps({
            "geometry": self.master.geometry(),
            "logging": self.options_logging.get()
        })

        if not exists(self.appdata_dir):
            mkdir(self.appdata_dir)

        with open(self.options_path, "w+") as options_file:
            options_file.write(options)

        self.master.destroy()

    def init_options(self):
        """Called on startup, loads, parses, and returns options from json."""

        if exists(self.options_path):
            with open(self.options_path, "r") as options_file:
                options_json = options_file.read()

            return loads(options_json)
        else:
            return None

    @staticmethod
    def ping(url):
        """
            Shell out to ping and return an integer result.
            Returns None if ping fails for any reason: timeout, bad hostname, etc.
        """

        flag = "-n" if platform == "win32" else "-c"
        result = run(["ping", flag, "1", "-w", "5000", url],
                     capture_output=True,
                     creationflags=DETACHED_PROCESS)
        output = result.stdout.decode("utf-8")
        try:
            duration = findall("\\d+ms", output)[0]
            return int(duration[:-2])
        except IndexError:
            return None