#/usr/bin/python3 import tkinter as tk from tkinter import simpledialog from enum import IntEnum import subprocess import getopt import sys import shutil import logging logger = logging.getLogger(__name__) # Prefer system-installed `smbus2`, otherwise fall back to local `smbus2.py`. try: import smbus2 except ImportError: import RPi.fake_smbus as smbus2 # type: ignore logger.warning('smbus2 not available; using fake smbus2.py') class XMode(IntEnum): RADIUS = 0 DIAMETER = 1 class Updated(IntEnum): POS_X = 0 POS_Z = 1 X_MODE = 2 FULLSCREEN = 3 class Axis: """ represent one axis in the model """ def __init__(self, name: str): self._position = 0 # raw position from encoder self._scale = 1.0 self._offset = 0.0 self._name = name def set_position(self, pos: float): """ set the position (mm).""" self._position = pos def get_position(self) -> float: """Return the position (mm).""" return self._position def set_offset(self, offset: float): """ set the offset (mm).""" self._offset = offset def get_offset(self) -> float: """Return the offset (mm).""" return self._offset def set_scale(self, scale: float): """ set the scale factor (mm per step).""" self._scale = scale def get_scale(self) -> float: """Return the scale factor (mm per step).""" return self._scale class Model: """ represent the DRO model """ def __init__(self, fullscreen = False): self._observers = [] self._x_mode = XMode.RADIUS # denote x axis mode (radius/diameter) self._axis = {} self._axis['x'] = Axis('x') self._axis['z'] = Axis('z') self._fullscreen = fullscreen def attatch(self, o): self._observers.append(o) def notify(self, updated = None): for o in self._observers: o.update(updated) def steps_to_position(self, axis, steps : int) -> float: """ convert steps to position in mm. Always radius for x axis """ if axis in self._axis: return steps * self._axis[axis].get_scale() return 0 def set_position(self, axis, pos : float): """ set position in mm. Always radius for x axis """ if axis in self._axis: if abs(self._axis[axis].get_position() - pos) >= 0.005: self._axis[axis].set_position(pos) self.notify(self._string_to_updated_axis(axis)) def set_offset(self, axis, offset : float): """ set offset in mm. Always radius for x axis """ if axis in self._axis: self._axis[axis].set_offset(offset) self.notify(self._string_to_updated_axis(axis)) def get_effective_position(self, axis) -> float: """ get position with offset applied """ if axis in self._axis: pos = self._axis[axis].get_position() + self._axis[axis].get_offset() """ adjust for x mode """ if axis == 'x' and self._x_mode == XMode.DIAMETER: return pos * 2.0 else: return pos return None def get_position(self, axis): """ get position without offset applied """ if axis in self._axis: return self._axis[axis].get_position() return None def get_x_mode(self): """ get x axis mode """ return self._x_mode def set_toggle_x_mode(self): """ toggle x axis mode """ if self._x_mode == XMode.RADIUS: self._x_mode = XMode.DIAMETER elif self._x_mode == XMode.DIAMETER: self._x_mode = XMode.RADIUS self.notify(Updated.X_MODE) def set_scale(self, axis, scale): if axis in self._axis: self._axis[axis].set_scale(scale) def set_fullscreen(self, v): self._fullscreen = v self.notify(Updated.FULLSCREEN) def is_fullscreen(self): return self._fullscreen def _string_to_updated_axis(self, axis_str): if axis_str == 'x': return Updated.POS_X if axis_str == 'z': return Updated.POS_Z return None class Controller: """ represent the controller in the MVC pattern """ def __init__(self, model, test_mode = False): self._model = model self._test_mode = test_mode self._bus = smbus2.SMBus(1) self._address = 0x08 # Arduino I2C Address def handle_x_position_update(self, steps): self._model.set_position('x', self._model.steps_to_position('x', steps)) def handle_z_position_update(self, steps): self._model.set_position('z', self._model.steps_to_position('z', steps)) def handle_btn_x0_press(self): self._model.set_offset('x', -self._model.get_position('x')) def handle_btn_z0_press(self): self._model.set_offset('z', -self._model.get_position('z')) def handle_btn_toggle_x_mode(self): self._model.set_toggle_x_mode() def hanlde_btn_x(self): title = 'current radius' if self._model.get_x_mode() == XMode.RADIUS else 'current diameter' pos = simpledialog.askfloat('x', title, initialvalue=0.0, minvalue=-4000.0, maxvalue=4000.0) if self._model.get_x_mode() == XMode.DIAMETER and pos is not None: pos = pos / 2.0 if pos is not None: self._model.set_offset('x', pos-self._model.get_position('x')) def hanlde_btn_z(self): pos = simpledialog.askfloat('z', 'current z position', initialvalue=0.0, minvalue=-4000.0, maxvalue=4000.0) if pos is not None: self._model.set_offset('z', pos-self._model.get_position('z')) def toggle_fullscreen(self, event=None): if self._model.is_fullscreen() == True: self._model.set_fullscreen(False) else: self._model.set_fullscreen(True) def end_fullscreen(self, event=None): self._model.set_fullscreen(False) def handle_btn_calc(self): calculator_cmd = shutil.which('galculator') if calculator_cmd != None: try: subprocess.Popen(calculator_cmd) except: print("can't start calculator") else: print("can't find calculator") def shutdown(self): if not self._test_mode: subprocess.call('sudo shutdown -h now', shell=True) else: print('will not shutdown in test mode') def poll_i2c_data(self): """ poll I2C data from Arduino """ try: data = self._bus.read_i2c_block_data(self._address, 0, 4) self.handle_x_position_update(int.from_bytes(bytes(data[0:2]), 'little', signed=True)) self.handle_z_position_update(int.from_bytes(bytes(data[2:4]), 'little', signed=True)) except IOError as e: logger.error(f"I2C read error: {e}") except OSError as e: logger.error(f"I2C OS error: {e}") class View: """ represent the view in the MVC pattern """ def __init__(self, model, test_mode = False): self._model = model self._model.attatch(self) self._controller = Controller(model, test_mode) self.window = tk.Tk() self.window.resizable(False, False) #self.window.attributes('-zoomed', zoomed) # This just maximizes it so we can see the window. It's nothing to do with fullscreen. self.window.attributes("-fullscreen", self._model.is_fullscreen()) w_frame = tk.Frame(self.window) w_frame.grid(row=0, column=0, sticky='n') e_frame = tk.Frame(self.window) e_frame.grid(row=0, column=1, sticky='n') # x frame x_frame = tk.Frame(w_frame, relief=tk.GROOVE, borderwidth=3) x_frame.grid(row=0, column=0, padx=5, pady=5) # use a StringVar for quicker updates from the main thread self.x_var = tk.StringVar(value=self._position_to_string(self._model.get_effective_position('x'))) self.x_label = tk.Label(x_frame, textvariable=self.x_var, anchor="e", font=("Helvetica", 80), fg="green", bg="black", width=6) self.x_label.grid(row=0, column=0, padx=5, pady=5) btn_set_x = tk.Button(master=x_frame, text="X", padx=5, pady=5, font=("Helvetica", 80), command=self._controller.hanlde_btn_x) btn_set_x.grid(row=0, column=1, padx=5, pady=5, sticky="nw") btn_set_x0 = tk.Button(master=x_frame, text="X_0", padx=5, pady=5, font=("Helvetica", 80), command=self._controller.handle_btn_x0_press) btn_set_x0.grid(row=0, column=2, padx=5, pady=5, sticky="nw") # Z frame z_frame = tk.Frame(w_frame, relief=tk.GROOVE, borderwidth=3) z_frame.grid(row=1, column=0, padx=5, pady=5)#, sticky="nw") self.z_var = tk.StringVar(value=self._position_to_string(self._model.get_effective_position('z'))) self.z_label = tk.Label(z_frame, textvariable=self.z_var, anchor="e", font=("Helvetica", 80), fg="green", bg="black", width=6) self.z_label.grid(row=0, column=0, padx=5, pady=5) btn_set_z = tk.Button(master=z_frame, text="Z", padx=5, pady=5, font=("Helvetica", 80), command=self._controller.hanlde_btn_z) btn_set_z.grid(row=0, column=1, padx=5, pady=5, sticky="nw") btn_set_z0 = tk.Button(master=z_frame, text="Z_0", padx=5, pady=5, font=("Helvetica", 80), command=self._controller.handle_btn_z0_press) btn_set_z0.grid(row=0, column=2, padx=5, pady=5, sticky="nw") # status frame status_frame = tk.Frame(w_frame, relief=tk.GROOVE, borderwidth=3) status_frame.grid(row=3, column=0, padx=5, pady=5, sticky='e') self.x_mode_var = tk.StringVar(value=str(self._mode_to_text())) self.x_mode_label = tk.Label(status_frame, textvariable=self.x_mode_var, font=("Helvetica", 80), fg="red") self.x_mode_label.grid(row=0, column=0, padx=5, pady=5, sticky="e") # button frame btn_frame = tk.Frame(e_frame, relief=tk.GROOVE, borderwidth=3) btn_frame.grid(row=0, column=0, padx=5, pady=5) btn_rd = tk.Button(master=btn_frame, text="r/D", padx=5, pady=5, font=("Helvetica", 80), width=4, command=self._controller.handle_btn_toggle_x_mode) btn_rd.grid(row=1, column=0, padx=5, pady=5, sticky="nw") btn_calc = tk.Button(master=btn_frame, text="Calc", padx=5, pady=5, font=("Helvetica", 80), width=4, command=self._controller.handle_btn_calc) btn_calc.grid(row=2, column=0, padx=5, pady=15, sticky="nw") #btn_enter = tk.Button(master=btn_frame, text="TBD", padx=5, pady=5, font=("Helvetica", 80), width=4)#, command=self._controller.horizontal_end) self.photo = tk.PhotoImage(file="power.png") btn_off = tk.Button(master=btn_frame, image=self.photo, command=self._controller.shutdown) btn_off.grid(row=3, column=0, padx=5, pady=15, sticky="nw") # bind keyboar inputs self.window.bind("", self._controller.toggle_fullscreen) self.window.bind("", self._controller.end_fullscreen) # schedule the GUI update loop on the main thread self._update_interval_ms = 100 # update at 10Hz by default self.window.after(self._update_interval_ms, self._poll_input) self.window.update() def _mode_to_text(self): if self._model.get_x_mode() == XMode.DIAMETER: return 'D' if self._model.get_x_mode() == XMode.RADIUS: return 'r' def _position_to_string(self, pos): return str("{:.2f}".format(pos)) def start(self): self.window.mainloop() def update_x(self): """ update x position in view (main thread) """ pos = self._model.get_effective_position('x') self.x_var.set(self._position_to_string(pos)) def update_z(self): """ update z position in view (main thread) """ pos = self._model.get_effective_position('z') self.z_var.set(self._position_to_string(pos)) def update(self, updated = None): """ called by model when something has changed """ if updated == Updated.POS_X: self.update_x() if updated == Updated.POS_Z: self.update_z() if updated == Updated.X_MODE: # main thread will pick this up through the scheduled update self.x_mode_var.set(str(self._mode_to_text())) self.update_x() if updated == Updated.FULLSCREEN: self.window.attributes("-fullscreen", self._model.is_fullscreen()) def _poll_input(self): """ scheduled function (runs on tkinter main loop) to update the view.""" try: # request data self._controller.poll_i2c_data() finally: # re-schedule self.window.after(self._update_interval_ms, self._poll_input) def main(): _zoomed = False _test = False try: opts, args = getopt.getopt(sys.argv[1:], "", ["zoomed", "test"]) except getopt.GetoptError as err: # print help information and exit: print(err) # will print something like "option -a not recognized" sys.exit(2) for o, a in opts: if o == "--zoomed": _zoomed = True elif o == "--test": _test = True else: assert False, "unhandled option" model = Model(_zoomed) view = View(model, _test) model.set_scale('x', -2.5/200) #negative to flip direction model.set_scale('z', 90/1000) if _test: try: x_test_encoder = smbus2.createTestEncoder() view.window.bind('a', x_test_encoder._rotate_cw) view.window.bind('z', x_test_encoder._rotate_ccw) z_test_encoder = smbus2.createTestEncoder() view.window.bind('s', z_test_encoder._rotate_cw) view.window.bind('x', z_test_encoder._rotate_ccw) except: logger.error("couldn't create test encoders...") view.start() if __name__ == '__main__': main()