diff --git a/README.md b/README.md index 20f7227..063e8f4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,12 @@ -# frontend-dev +# Smaage Frontend Prototype +## Install Requirements + +```bash +pip install -r requirements.txt +``` + +## Run +```bash +python -m frontend.app +``` \ No newline at end of file diff --git a/frontend/__init__.py b/frontend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/frontend/app.py b/frontend/app.py new file mode 100644 index 0000000..2c86ec8 --- /dev/null +++ b/frontend/app.py @@ -0,0 +1,120 @@ +import tkinter as tk +from tkinter import ttk +import threading + +from .serial_reader import WeightReader +from .config import DEFAULT_CALIB, DISPLAY_TYPES + +class WeightApp(tk.Tk): + def __init__(self, weight_reader): + super().__init__() + self.weight_reader: WeightReader = weight_reader + + self.toolbar = tk.Frame(self, padx=10) + self.toolbar.pack(side=tk.LEFT) + + #### Connection Settings #### + self.connection_settings = tk.Frame(self.toolbar, pady=10) + self.scan_ports_button = ttk.Button(self.connection_settings, text="Scan Devices", command=self.update_devices) + self.scan_ports_button.pack() + self.port_label = ttk.Label(self.connection_settings, text="Port:") + self.port_label.pack(side=tk.LEFT) + self.port = ttk.Combobox(self.connection_settings, values=[]) + self.update_devices() + self.port.pack(side=tk.LEFT) + self.connection_settings.pack() + self.connect_button = ttk.Button(self.toolbar, text="Connect", command=self.connect) + self.connect_button.pack() + + #### Weight Reader Settings #### + self.reader_settings = tk.Frame(self.toolbar, pady=30) + self.reader_settings.pack() + + self.calib_factor = tk.Frame(self.reader_settings) + self.calib_factor.pack() + self.calib_label = ttk.Label(self.calib_factor, text="Calibration Factor:") + self.calib_label.pack() + self.calib_weight = ttk.Combobox(self.calib_factor, values=[100], width=5) + self.calib_weight.set(100) + self.calib_weight.pack(side=tk.LEFT) + self.calib_measurements = ttk.Entry(self.calib_factor) + self.calib_measurements.insert(0, DEFAULT_CALIB) + self.calib_measurements.pack(side=tk.LEFT) + self.update_calib_button = ttk.Button(self.reader_settings, text="Update Calibration", command=self.update_calib) + self.update_calib_button.pack() + + self.display_type = tk.Frame(self.toolbar, pady=20) + self.display_type.pack() + self.display_type_label = ttk.Label(self.display_type, text="Visual:") + self.display_type_label.pack(side=tk.LEFT) + self.display_type_select = ttk.Combobox(self.display_type, values=[t.value for t in DISPLAY_TYPES]) + self.display_type_select.set(DISPLAY_TYPES.NUMBER.value) + self.display_type_select.pack(side=tk.LEFT) + + + #### Display #### + self.main_frame = tk.Frame(self, width=144, height=168, padx=50) + self.main_frame.pack(side=tk.RIGHT) + + #### Actions #### + self.actions = tk.Frame(self.main_frame) + self.actions.pack() + self.tare_button = ttk.Button(self.actions, text="Tare", command=self.weight_reader.tare) + self.tare_button.pack() + + self.canvas = tk.Canvas(self.main_frame, width=144, height=168, background='white') + self.canvas.pack() + + self.label = self.canvas.create_text(50, 68, text="0.0 g", font=("Arial", 18), fill='black', justify='left') + + self.update_weight_display() + + self.focus_force() + + + def connect(self): + if self.weight_reader.serial is None: + port = self.port.get() + self.weight_reader.connect(port) + self.connect_button.config(text="Disconnect") + self.connection_settings.pack_forget() + else: + self.weight_reader.disconnect() + self.connect_button.config(text="Connect") + self.connect_button.pack_forget() + self.reader_settings.pack_forget() + self.actions.pack_forget() + self.connection_settings.pack() + self.connect_button.pack() + self.reader_settings.pack() + self.actions.pack() + + def update_devices(self): + self.weight_reader.scan_devices() + self.port.config(values=self.weight_reader.ports) + if len(self.weight_reader.ports) > 0: + self.port.set(self.weight_reader.ports[0]) + + def update_calib(self): + self.weight_reader.calib_factor = self.calib_weight.get() / float(self.calib_measurements.get()) + + + def update_weight_display(self): + weight = self.weight_reader.value + self.canvas.itemconfig(self.label, text=f"{weight:.1f} g") + self.after(100, self.update_weight_display) + +def main(): + weight_reader = WeightReader() + threading.Thread(target=weight_reader.read_weights, daemon=True).start() + + app = WeightApp(weight_reader) + app.protocol("WM_DELETE_WINDOW", lambda: on_closing(app, weight_reader)) + app.mainloop() + +def on_closing(app, weight_reader): + weight_reader.stop() + app.destroy() + +if __name__ == "__main__": + main() diff --git a/frontend/config.py b/frontend/config.py new file mode 100644 index 0000000..806d5f5 --- /dev/null +++ b/frontend/config.py @@ -0,0 +1,14 @@ +from enum import Enum + +DEFAULT_CALIB = 307333.83 +DEFAULT_CALIB_WEIGHT = 100. + +MOV_AVG_DEFAULTS = { + "window_size": 10, + "decimals": 1, + "reset_threshold": 0.5, + "ignore_samples": 2 +} + +class DISPLAY_TYPES(Enum): + NUMBER = 'number' \ No newline at end of file diff --git a/frontend/serial_reader.py b/frontend/serial_reader.py new file mode 100644 index 0000000..ef14df8 --- /dev/null +++ b/frontend/serial_reader.py @@ -0,0 +1,90 @@ +import time + +from statistics import mean + +from serial import Serial +from serial.tools import list_ports + +from .config import DEFAULT_CALIB, DEFAULT_CALIB_WEIGHT, MOV_AVG_DEFAULTS + +class WeightReader: + @property + def value(self): + return (self.current_raw_weight - self._tare) * self.calib_factor + + @property + def calib_factor(self): + return self._calib_factor + + @calib_factor.setter + def calib_factor(self, value): + self.calib_factor = value + self._raw_reset_threshold = self.reset_threshold / value + + def __init__(self): + self.running = True + self.ports = [d.device for d in list_ports.grep('usbmodem')] + + self.serial = None + + self._calib_factor = DEFAULT_CALIB_WEIGHT / DEFAULT_CALIB + self.window_size = MOV_AVG_DEFAULTS['window_size'] + self.reset_threshold = MOV_AVG_DEFAULTS['reset_threshold'] + self._raw_reset_threshold = MOV_AVG_DEFAULTS['reset_threshold'] / self._calib_factor + self.ignore_samples = MOV_AVG_DEFAULTS['ignore_samples'] + + self._tare = 0.0 + + self.window = [] + self.current_raw_weight = 0 + self.ignored_samples = 0 + + + def scan_devices(self): + self.ports = [d.device for d in list_ports.grep('usbmodem')] + + def connect(self, port, baudrate=115200): + self.serial = Serial(port, baudrate) + + def disconnect(self): + self.serial.close() + self.serial = None + + def read_weights(self): + while self.running: + if self.serial is not None: + try: + line = self.serial.readline().decode('utf-8') + raw_weight = int(line.split(',')[1]) + + self.filter(raw_weight) + except: + pass + else: + time.sleep(1) + + def stop(self): + self.running = False + + def filter(self, raw_weight): + if len(self.window) < self.window_size: + self.window.append(raw_weight) + self.current_raw_weight = mean(self.window) + else: + out_of_threshold = abs(self.current_raw_weight - raw_weight) > self._raw_reset_threshold + if out_of_threshold and\ + self.ignored_samples < self.ignore_samples: + self.ignored_samples += 1 + + elif out_of_threshold: + self.ignored_samples = 0 + self.window = [raw_weight] + self.current_raw_weight = raw_weight + + else: + self.ignored_samples = 0 + self.window.append(raw_weight) + self.current_raw_weight = mean(self.window) + + def tare(self): + self._tare = self.current_raw_weight \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a3402b7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +numpy +pyserial \ No newline at end of file