384 lines
14 KiB
Python
384 lines
14 KiB
Python
#/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("<F11>", self._controller.toggle_fullscreen)
|
|
self.window.bind("<Escape>", 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()
|