Search code examples
pythonuser-interfacetkintermodelstructure

Python: GUI and back-end


I am working on a data acquisition project, building multiple data-monitoring/controlling programs for different instruments (voltmeter, camera, etc.) using python. I am using python3 and tkinter (due to its open license) as my GUI.

The basic structure for each instrument right now is:

import packages

class all_GUI():
    def __init__():
    
        device = volt_device()

   functions linking GUI elements to HW calls


    mainloop()

class volt_device():
    def __init__():

    functions to access HW functionality


mainapp = all_GUI()

It more-less works, but there are many calls between GUI and hardware classes all over the code. If I want to reuse GUI part of the code and link it with another hardware board I pretty much have to rewrite the whole thing. As you can imagine this is not very appealing :-)

I suppose class volt_device can be moved into a separate file and loaded as needed. But because GUI calls many functions from HW part, each HW file (supporting different board, for example) would have to have the exact same naming convention. Not terrible, but not the best either I think.

I was looking into separating GUI and HW as much as possible, but had some difficulties. I was looking into a model-view-controller pattern, but could not make it work. My idea was having three programs:

import GUI
import HW

objGUI = 
objHW = 

link functions to interface objects

mainloop()

class GUI():
    def __init__():

    build GUI here with all elements
    (this is getting sticky since I need to define functions to be executed when GUI values change
    or buttons are pushed)

Have multiple hardware files supporting different instruments.

class HW():
   def __init__():
 
   define hardware board, have functions to change/monitor values

Ideally, I would have a relatively simple HW file (file 3). To have whole new virtual device I would have to load GUI portion (file 2; unmodified) and write a simple "controller" (file 1) linking GUI elements to HW functions. Sounds simple ...

I got stuck when I tried to link GUI and HW together. I was not sure how to properly address GUI elements and assign them appropriate HW call/function. Perhaps the whole idea is flawed and the GUI/HW separation needs to approached differently ...

I am sure this problem must have been tackled before I just cannot find it ... or figure it out right now. I would greatly appreciate any suggestions and/or coding references you might have.

Thank you.

Radovan


Solution

  • ...would have to have the exact same naming convention. Not terrible, but not the best either I think.

    On the contrary, that is probably the best method. In essence you would create a generic interface and have each "board" implement the interface with it's specifics or subclass something that does. Then you create a class for tkinter that can build an interface from the methods and arguments.

    Both displays were automatically generated and one way or another everything leads back to the most basic component.

    enter image description here

    very generic and simplified example:

    import tkinter as tk, abc
    from typing import List, Tuple, Callable, Iterable, Dict
    import inspect
    
    
    #create formal interface
    class IGenericBoard(metaclass=abc.ABCMeta):
        @classmethod
        def __subclasshook__(cls, subclass):
            isinterface  = hasattr(subclass, 'read_pin')  and callable(subclass.read_pin)
            isinterface &= hasattr(subclass, 'write_pin') and callable(subclass.write_pin)
            return isinterface
    
        @abc.abstractmethod
        def generic_pin_read(self, pin:int) -> int:
            raise NotImplementedError
    
        @abc.abstractmethod
        def generic_pin_write(self, pin:int, data:int):
            raise NotImplementedError
    
    
    #implement IGenericBoard        
    class GenericBoard(IGenericBoard):
        @property
        def model(self):
            #the "model type" for this board instance
            return type(self).__name__
            
        @property
        def prefix(self) -> List:
            #the prefix(es) to use when finding functions
            return self._prefix if isinstance(self._prefix , (List, Tuple)) else [self._prefix]
            
        @property
        def msgvar(self) -> tk.StringVar:
            #the output message var
            return self._msgvar
            
        @property
        def attributes(self) -> Dict:
            #get everything in one shot ~ for **kwargs
            return dict(
                model =self.model ,
                prefix=self.prefix,
                msgvar=self.msgvar,
            )
    
        def __init__(self):
            self._prefix = 'generic'
            self._msgvar = tk.StringVar()
    
        def generic_pin_read(self, pin:int) -> int:
            self._msgvar.set(f'reading pin {pin}')
            #... really do this
            return 0
    
        def generic_pin_write(self, pin:int, data:int):
            self._msgvar.set(f'writing {data} on pin {pin}')
            #... really do this
    
    
    #"final" class
    class LEDBoard(GenericBoard):
        def __init__(self):
            GenericBoard.__init__(self)
            self._prefix = self.prefix + ['led']
    
        def led_blink_write(self, pin:int=13):
            self.generic_pin_write(pin, 1)
            self._msgvar.set(f'blinking on pin {pin}')
            #... really do this
    
    
    ''' tkBaseBoard
            the baseclass for all "tk[Version]Board" classes
            generates form interfaces for methods with the proper prefix(es)
    '''
    class tkBaseBoard(tk.Frame):
        def __init__(self, master, model, msgvar, prefix, **kwargs):
            tk.Frame.__init__(self, master, **{'bd':2, 'relief':'raised', **kwargs})
            self.grid_columnconfigure(0, weight=1)
    
            #board model label
            tk.Label(self, text=model, font="Consolas 12 bold").grid(row=0, column=0, sticky='w')
    
            #message output from board
            self.output_ent = tk.Entry(self, width=30, textvariable=msgvar)
            self.output_ent.grid(row=2, column=0, sticky='e')
    
            #common feature label configuration
            self.lbl_opts = dict(width=6, anchor='w', font='Consolas 10')
            
            #annotation conversion
            self.conversion = {
                "<class 'int'>"  :lambda: tk.IntVar(),
                "<class 'str'>"  :lambda: tk.StringVar(),
                "<class 'bool'>" :lambda: tk.BooleanVar(),
                "<class 'float'>":lambda: tk.DoubleVar(),
            }
    
            #build a feature for every "feat_" suffixed method
            for feature in [func for func in dir(self) if callable(getattr(self, func)) and func.split('_')[0] in prefix]:
                self._add_feature(feature)
    
        #create a list of variable values
        def __tovalue(self, vars) -> List[int]:
            return [v.get() for v in vars]
            
        #dynamically create the gui for a method
        def _add_feature(self, feature):
            main = tk.Frame(self)
            main.grid(sticky='we')
            
            #parse feature components
            command = getattr(self, feature)
            featcmp = feature.split('_')
            
            if featcmp and len(featcmp) == 3:
                _, label, action = featcmp
                
                #create a list of Vars based on command argument types
                args, vars = inspect.signature(command).parameters, []
                for name in args:
                    try:
                        #convert annotations to the proper tk.[Type]Var
                        vars.append(self.conversion[str(args[name].annotation)]())
                    except KeyError:
                        #fallback to StringVar
                        vars.append(tk.StringVar())
        
                #create label and button for this command
                tk.Label(main, text=label, **self.lbl_opts).grid(row=0, column=0, sticky='e')
                tk.Button(main, text=action, width=5, command=lambda v=vars: command(*self.__tovalue(v))).grid(row=0, column=1, sticky='w', padx=8)
        
                #create an Entry for every argument in command
                for i, v in enumerate(vars):
                    tk.Entry(main, width=2, textvariable=v).grid(row=0, column=i+2, sticky='w')
        
                #give all the weight to the last row
                main.grid_columnconfigure(i+2, weight=1)
            else:
                #feature name components did not pass expectations
                raise ValueError('ValueError: feature component must consist of three underscore-seperated parts as: PREFIX_LABEL_ACTION')
    
    
    ##EXAMPLES OF THE ULTIMATE IMPLEMENTATION ALL OF THE ABOVE ALLOWS
    
    
    #generate GenericBoard display
    class tkGenericBoard(tkBaseBoard, GenericBoard):
        def __init__(self, master, **kwargs):
            GenericBoard.__init__(self)
            tkBaseBoard.__init__(self, master, **self.attributes, **kwargs)
    
    
    #generate LEDBoard display
    class tkLEDBoard(tkBaseBoard, LEDBoard):
        def __init__(self, master, **kwargs):
            LEDBoard.__init__(self)
            tkBaseBoard.__init__(self, master, **self.attributes, **kwargs)
    
    
    ##EXAMPLE BASE USAGE
    
    
    if __name__ == '__main__':
        root = tk.Tk()
        root.title('Example')
        root.configure(padx=2, pady=2)
        
        tkGenericBoard(root).grid()
        
        tkLEDBoard(root).grid()
        
        root.mainloop()