Files
pi-dro/dro.py
2026-03-21 23:51:53 +01:00

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()