Search code examples
pythontkintercountdown

using a simple timer loop within a function


Purpose: Multi menu program that when a mode is selected it will execute that mode indefinitely within it's own loop with a visible timer of prefixed time for example 60sec. It will be used in a Raspberry Pi to control some automation. I have succeeded in making everything except the timer. I tried with tk timer, countdown, whiles and fors, with partial or no success. It's probably due to my inexperience and the fact that I'm not clear about when or where the variables are declared.

Any help is appreciated, code follows.

import tkinter as tk
from tkinter import *
import sys
import os
import time

if os.environ.get('DISPLAY','') == '':
    print('no display found. Using :0.0')
    os.environ.__setitem__('DISPLAY', ':0.0')

def mode1():
        print("Mode 1")
        #do stuff

def mode2():
        print("Mode 2")
        #do stuff

def mode3():
        print("Mode 3")
        #do stuff

master = tk.Tk()
master.attributes('-fullscreen', True)
master.title("tester")
master.geometry("800x480")

label1 = tk.Label(master, text='Choose Mode',font=30)
label1.pack()

switch_frame = tk.Frame(master)

switch_frame.pack()

switch_variable = tk.StringVar()
off_button = tk.Radiobutton(switch_frame, bg="red", text="Off", variable=switch_variable,
                            indicatoron=False, value="off", width=20, command=quit)
m1_button = tk.Radiobutton(switch_frame, selectcolor="green", text="Mode 1", variable=switch_variable,
                            indicatoron=False, value="m1", width=20, height=10, command=mode1)
m2_button = tk.Radiobutton(switch_frame, selectcolor="green", text="Mode 2", variable=switch_variable,
                            indicatoron=False, value="m2", width=20, height=10, command=mode2)
m3_button = tk.Radiobutton(switch_frame, selectcolor="green", text="Mode 3", variable=switch_variable,
                             indicatoron=False, value="m3", width=20, height=10, command=mode3)
off_button.pack(side="bottom")
m1_button.pack(side="left")
m2_button.pack(side="left")
m3_button.pack(side="left")

timertext = tk.Label(master, text="Next execution in:")
timertext.place (x=10, y=150)
#timerlabel = tk.Label(master, text=countdown)
#timerlabel.place (x=200, y=150)

master.mainloop()

I tried including this timer to my script, but instead of showing the timer in a separate window, I tried to include it in the parent window.

import tkinter as tk
from tkinter import *
import sys
import os
import time


class ExampleApp(tk.Tk):
    def __init__(self):
        tk.Tk.__init__(self)
        self.label = tk.Label(self, text="", width=10)
        self.label.pack()
        self.remaining = 0
        self.countdown(10)

    def countdown(self, remaining = None):
        if remaining is not None:
            self.remaining = remaining

        if self.remaining <= 0:
            self.label.configure(text="Doing stuff!")
            self.update_idletasks()
            time.sleep(0.3)
            os.system('play -nq -t alsa synth {} sine {}'.format(0.5, 440))
            #do stuff
            self.remaining = 10
            self.countdown()
        else:
            self.label.configure(text="%d" % self.remaining)
            self.remaining = self.remaining - 1
            self.after(1000, self.countdown)


if __name__ == "__main__":
    app = ExampleApp()
    app.mainloop()
`


Solution

  • Using time.sleep is not practical within tkinter app - basically it will block the whole application rendering it unresponsive. To avoid this, one would use threading and enclose blocking parts in a thread so the tkinter's mainloop is not blocked. You can see some solutions here.

    Though the tkinter's self.after can be used as another alternative - as long as the underlying tasks you want to run are as quick as possible - eg. (almost) non-blocking, it will still slowly throw your timer off but that can be worked with (using time-aware scheduler instead of countdown).

    For you, this means that the play command should either return immediately or take less time to execute then your countdown (or in my example, scheduling time of one loop).

    import functools
    import os
    import time
    import tkinter as tk
    
    BUTTON_ACTIVE_BG_COLOR = "green"
    BUTTON_INACTIVE_BG_COLOR = "grey"
    
    BASE_COMMAND = "echo {}"  # 'play -nq -t alsa synth {} sine {}'.format(0.5, 440)
    CMD_LONG_BLOCKING = "sleep 5"
    
    # mode_name, mode_command
    MODES = [
        ("Mode 1", BASE_COMMAND.format("mode1")),
        ("Mode 2", BASE_COMMAND.format("mode2")),
        ("Mode 3", BASE_COMMAND.format("mode3")),
        ("Mode BLOCKING TEST", CMD_LONG_BLOCKING),
    ]
    
    SCHEDULER_DELTA = 10  # run every N s
    
    
    def get_next_sched_time():
        return time.time() + SCHEDULER_DELTA
    
    
    class ExampleApp(tk.Tk):
        running = False
        current_mode_command = None
        current_active_button = None
        scheduler_next = time.time() + SCHEDULER_DELTA
    
        # helper for start/stop button to remember remaining time of last scheduler loop
        scheduler_last_remaining_time = SCHEDULER_DELTA
    
        def __init__(self):
            tk.Tk.__init__(self)
            self.label = tk.Label(self, text=self.running, width=10)
            self.label.pack(fill=tk.X, pady=5)
    
            self.create_mode_buttons()
    
            self.stop_button = tk.Button(
                self, text="Stopped", command=self.run_toggle, width=30, bg=None
            )
            self.stop_button.pack(pady=100)
    
            self.after(0, self.scheduler)
    
        def create_mode_buttons(self):
            for mode_name, mode_cmd in MODES:
                mode_button = tk.Button(
                    self, text=mode_name, width=15, bg=BUTTON_INACTIVE_BG_COLOR
                )
    
                mode_button.configure(
                    command=functools.partial(
                        self.button_set_mode, cmd=mode_cmd, button=mode_button
                    )
                )
                mode_button.pack(pady=5)
    
        def run_toggle(self):
            """
            Method for toggling timer.
            """
            self.running = not self.running
            self.stop_button.configure(text="Running" if self.running else "Stopped")
            self.update_idletasks()
    
            if self.running:
                # False => True
                self.scheduler_next = time.time() + self.scheduler_last_remaining_time
            else:
                # True => False
                # save last remaining time
                self.scheduler_last_remaining_time = self.scheduler_next - time.time()
    
        def color_active_mode_button(self, last_active, active):
            if last_active:
                last_active.configure(bg=BUTTON_INACTIVE_BG_COLOR)
    
            active.configure(bg=BUTTON_ACTIVE_BG_COLOR)
            self.update_idletasks()
    
        def button_set_mode(self, cmd, button):
            """
            Method for changing the 'mode' of next execution.
    
            Clicking the buttons only changes what command will be run next.
            Optionally it can (should?) reset the timer.
            """
            if self.current_active_button == button:
                return
    
            self.color_active_mode_button(
                last_active=self.current_active_button, active=button
            )
            self.current_active_button = button
            print("Mode changed to:", button["text"])
    
            # self.scheduler_next = get_next_sched_time()  # Reset countdown
            self.current_mode_command = cmd
    
        def scheduler(self):
            if self.running:
                time_now = time.time()
                if time_now >= self.scheduler_next:
                    # TIME TO RUN
                    # this can block the whole app
                    print("Executing mode: ", self.current_mode_command)
                    os.system(self.current_mode_command)
    
                    # Reusing the time before running the command instead of new/current `time.time()`.
                    # Like this the time it took to execute that command won't interfere with scheduling.
                    # If the current "now" time would be used instead, then next scheduling time(s)
                    # would be additionally delayed by the time it took to execute the command.
                    self.scheduler_next = time_now + SCHEDULER_DELTA
    
                    # Be wary that the time it took to execute the command
                    # should not be greater then SCHEDULER_DELTA
                    # or the >scheduler won't keep up<.
    
                self.label.configure(
                    text="Remaining: {:06.2f}".format(self.scheduler_next - time.time())
                )
                self.update_idletasks()
            # This will re"schedule the scheduler" within tkinter's mainloop.
            self.after(10, self.scheduler)
    
    
    if __name__ == "__main__":
        app = ExampleApp()
        app.mainloop()
    

    Once running, try the "Mode" with blocking sleep, once it executes the label with remaining time will stop and the UI freeze because whole tkinter app is now blocked. If this isn't enough for your use case then you will need threads.