diff --git a/SECURITY.md b/SECURITY.md index 376ef32..a71b38f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,11 +4,12 @@ Currently only the newest versions get security updates as security updates are also part of a release. -Only Version 3 is supported due to the poor code quality of V2.3.0 and below. +Only Version 3.1 and later are supported due to the poor code quality of V2.3.0 and different UI before. | Version | Supported | | ------- | ------------------ | -| 3.0.0 | ✅ | +| 3.1.X | ✅ | +| 3.0.X | ✅ | | 2.3.0 | ❎ | | 2.2.0 | ❎ | | 2.1.0 | ❎ | diff --git a/biogascontrollerapp.py b/biogascontrollerapp.py index 16de053..1918f27 100644 --- a/biogascontrollerapp.py +++ b/biogascontrollerapp.py @@ -4,9 +4,9 @@ # ╰────────────────────────────────────────────────╯ # # So you would like to read the source code? Nice! -# Just be warned, this application uses Thread and a UI Toolkit called -# Kivy to run. If you are unsure of what functions do, consider -# checking out the kivy docs at https://kivy.org/doc. +# Just be warned, this application uses Thread and a UI Toolkit called +# Kivy to run. If you are unsure of what functions do, consider +# checking out the kivy docs at https://kivy.org/doc. # It also uses the pyserial library for communication with the micro- # controller with RS232 # @@ -15,25 +15,30 @@ # Load the config file import configparser import time + config = configparser.ConfigParser() config.read("./config.ini") # Introducing tariffs to Python imports. # It was too funny of an idea to miss out on -# You can enable or disable this in the config. +# You can enable or disable this in the config. # It is disabled by default if config["Tariffs"]["impose_tariffs"] == "True": try: import tariff - tariff.set({ - "kivy": int(config["Tariffs"]["kivy_rate"]), - "serial": int(config["Tariffs"]["pyserial_rate"]), - }) + tariff.set( + { + "kivy": int(config["Tariffs"]["kivy_rate"]), + "serial": int(config["Tariffs"]["pyserial_rate"]), + } + ) except Exception as e: print(e) - print("You cannot evade the tariffs. I will impose impose a tariff of 1000000% on the launch of this app!") + print( + "You cannot evade the tariffs. I will impose impose a tariff of 1000000% on the launch of this app!" + ) time.sleep(2000000) import os @@ -43,7 +48,6 @@ from lib.com import Com, ComSuperClass import lib.test.com - # Load config and disable kivy log if necessary if config["Dev"]["verbose"] == "True": pass @@ -52,27 +56,25 @@ else: # Load kivy modules. Kivy is the UI framework used. See https://kivy.org -# from kivy.core.window import Window, Config +from kivy.core.window import Window from kivy.uix.screenmanager import ScreenManager from kivymd.app import MDApp -# Store the current app version -app_version = f"{config['Info']['version']}{config['Info']['subVersion']}" +# Set Window size +Window.size = (int(config["UI"]["width"]), int(config["UI"]["height"])) # ╭────────────────────────────────────────────────╮ # │ Screens │ # ╰────────────────────────────────────────────────╯ -# Import all the screens (= pages) used in the app +# Import all the screens (= pages) used in the app from gui.home.home import HomeScreen -from gui.credits.credits import CreditsScreen from gui.program.program import ProgramScreen from gui.about.about import AboutScreen from gui.main.main import MainScreen - # ╭────────────────────────────────────────────────╮ # │ Screen Manager │ # ╰────────────────────────────────────────────────╯ @@ -84,29 +86,54 @@ class BiogasControllerApp(MDApp): @override def build(self): - com: ComSuperClass = Com() + # Configure com + conn = config["Connection"] + filters = [x for x in conn["filters"].split(",")] + com: ComSuperClass = Com( + int(conn["baudrate"]) if conn["baudrate"] != None else 19200, filters + ) if config["Dev"]["use_test_library"] == "True": - com = lib.test.com.Com() + com = lib.test.com.Com( + int(config["Dev"]["fail_sim"]), + int(conn["baudrate"]) if conn["baudrate"] != None else 19200, + filters, + ) + com.set_port_override(conn["baudrate"]) - self.theme_cls.theme_style = "Dark" - self.theme_cls.primary_palette = "Green" - self.theme_cls.accent_palette = "Lime" - self.theme_cls.theme_style_switch_animation = True - self.theme_cls.theme_style_switch_animation_duration = 0.8 + self.theme_cls.theme_style = ( + "Dark" if config["UI"]["theme"] == None else config["UI"]["theme"] + ) + self.theme_cls.material_style = "M3" + self.theme_cls.primary_palette = ( + "Green" + if config["UI"]["primary_color"] == None + else config["UI"]["primary_color"] + ) + self.theme_cls.accent_palette = ( + "Lime" + if config["UI"]["accent_color"] == None + else config["UI"]["accent_color"] + ) + self.theme_cls.theme_style_switch_animation = False self.icon = "./BiogasControllerAppLogo.png" - self.title = "BiogasControllerApp-" + app_version + self.title = "BiogasControllerApp-V3.1.0" self.screen_manager.add_widget(HomeScreen(com, name="home")) self.screen_manager.add_widget(MainScreen(com, name="main")) self.screen_manager.add_widget(ProgramScreen(com, name="program")) - self.screen_manager.add_widget(CreditsScreen(name="credits")) self.screen_manager.add_widget(AboutScreen(name="about")) return self.screen_manager + def change_theme(self): + self.theme_cls.theme_style = ( + "Dark" if self.theme_cls.theme_style == "Light" else "Light" + ) + # Disallow this file to be imported if __name__ == "__main__": - print(""" + print( + """ ┏━━┓━━━━━━━━━━━━━━━━━━━━┏━━━┓━━━━━━━━━┏┓━━━━━━━━┏┓━┏┓━━━━━━━━┏━━━┓━━━━━━━━ ┃┏┓┃━━━━━━━━━━━━━━━━━━━━┃┏━┓┃━━━━━━━━┏┛┗┓━━━━━━━┃┃━┃┃━━━━━━━━┃┏━┓┃━━━━━━━━ ┃┗┛┗┓┏┓┏━━┓┏━━┓┏━━┓━┏━━┓┃┃━┗┛┏━━┓┏━┓━┗┓┏┛┏━┓┏━━┓┃┃━┃┃━┏━━┓┏━┓┃┃━┃┃┏━━┓┏━━┓ @@ -116,9 +143,10 @@ if __name__ == "__main__": ━━━━━━━━━━━┏━┛┃━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┃┃━━┃┃━━ ━━━━━━━━━━━┗━━┛━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┗┛━━┗┛━━ - Version 3.0.0 + Version 3.1.0 => Initializing.... - """) + """ + ) BiogasControllerApp().run() print("\n => Exiting!") diff --git a/changelog b/changelog index d40fc1a..3880c27 100644 --- a/changelog +++ b/changelog @@ -1,11 +1,27 @@ ***CHANGELOG*** +V3.1.0 +- Completely redesigned User Interface using KivyMD +- Added config option for themes -V3.0-beta -- Redesigned GUI +V3.0.1 +- Install script fixes +- Packaging fixes + + +V3.0.0 +- Small UI fixes - Consolidated multiple previously separate screens - Completely rewritten backend - Improved stability - Cleaned, documented code +- Reduced overhead of connecting +- Improved hooking reliability +- Removed installer, simpler setup now possible +- Removed official MacOS support as it didn't really work before anyway +- Added additional config options +- Improved linguistics +- Bugfixes + OLD VERSIONS ------------ diff --git a/config.ini b/config.ini index 43f44fe..7d90dbd 100644 --- a/config.ini +++ b/config.ini @@ -1,21 +1,24 @@ -[Ports] -specificport = None +[Connection] +override_port = None +baudrate = 19200 +# List the names as which the adapter cable will show up separated by commas below +# For ENATECH, the below is likely correct. +filters = USB-Serial Controller, Prolific USB-Serial Controller [UI] -sizeh = 600 -sizew = 800 +height = 600 +width = 800 +# Can be Dark or Light +theme = Dark +primary_color = Green +accent_color = Lime [Dev] -verbose = True -log_level = DEBUG -use_test_library = True +verbose = False +use_test_library = False +fail_sim = 10 [Tariffs] impose_tariffs = False kivy_rate = 50 pyserial_rate = 500 - -[Info] -version = V2.3.0 -subversion = - diff --git a/gui/about/about.kv b/gui/about/about.kv index 71b1688..39f4a7c 100644 --- a/gui/about/about.kv +++ b/gui/about/about.kv @@ -1,37 +1,52 @@ : name: "about" - canvas.before: - Color: - rgba: (10,10,10,0.1) - Rectangle: - size: self.size - pos: self.pos - GridLayout: - cols: 1 + MDFloatLayout: + Image: + source: "BiogasControllerAppLogo.png" + pos_hint: {"top": 0.9} + size_hint_y: .3 + radius: 36, 36, 0, 0 + allow_stretch: True + keep_ratio: True + + MDGridLayout: + cols: 1 + MDLabel: + text: "About" + font_size: 40 + halign: 'center' + valign: 'center' + bold: True + italic: True + theme_text_color: 'Secondary' + pos_hint: {'center_x': 0, 'center_y': 0} + + MDFillRoundFlatButton: + pos_hint: {'x': 0.1, 'y': 0.05} + text: "Back" + on_release: + app.root.current = "home" + root.manager.transition.direction = "up" + + MDFillRoundFlatButton: + pos_hint: {'right': 0.9, 'y': 0.05} + text: "Report a Bug" + on_release: + root.goto("issues") + + MDFillRoundFlatButton: + pos_hint: {'right': 0.48, 'y': 0.05} + text: "Wiki" + on_release: + root.goto("wiki") + + MDFillRoundFlatButton: + pos_hint: {'x': 0.52, 'y': 0.05} + text: "Repo" + on_release: + root.goto("repo") Label: - text: "About" - font_size: 40 - color: (0, 113, 0, 1) - bold: True - FloatLayout: - GridLayout: - pos_hint: {"x":0.05, "y":0.05} - size_hint: 0.9, 0.9 - cols: 3 - Button: - text: "Back" - background_color: (255,0,0,0.6) - on_release: - app.root.current = "home" - root.manager.transition.direction = "up" - Button: - text: "Report a\nBug" - background_color: (255,0,0,0.6) - on_release: - root.report_issue() - Button: - text: "Credits" - background_color: (255,0,0,0.6) - on_release: - app.root.current = "credits" - root.manager.transition.direction = "left" + text: "This is a simple controller application that allows you to read data from and configure the microcontroller used in ENATECH at KSWO. It is written in Python using KivyMD as its UI framework.\n\nThis software is free Software licensed under the GNU General Public License Version 3 and as such comes with absolutely no warranty." + pos_hint: {'x': 0.05, 'top': 0.42} + text_size: self.width, None + size_hint: 0.9, None diff --git a/gui/about/about.py b/gui/about/about.py index b18970f..645cf60 100644 --- a/gui/about/about.py +++ b/gui/about/about.py @@ -1,13 +1,28 @@ from kivy.uix.screenmanager import Screen +from kivymd.uix.dialog import MDDialog +from kivymd.uix.button import MDFlatButton from kivy.lang import Builder import webbrowser -from gui.popups.popups import SingleRowPopup - class AboutScreen(Screen): - def report_issue(self): - SingleRowPopup().open("Opened your web-browser") - webbrowser.open('https://github.com/janishutz/BiogasControllerApp/issues', new=2) + def __init__(self, **kw): + self.opened_web_browser_dialog = MDDialog( + title="Open Link", + text="Your webbrowser has been opened. Continue there", + buttons=[ + MDFlatButton(text="Ok", on_release=lambda _: self.opened_web_browser_dialog.dismiss()), + ], + ) + super().__init__(**kw) + + def goto(self, loc: str): + if loc == "wiki": + webbrowser.open('https://github.com/janishutz/BiogasControllerApp/wiki', new=2) + elif loc == "issues": + webbrowser.open('https://github.com/janishutz/BiogasControllerApp/issues', new=2) + elif loc == "repo": + webbrowser.open('https://github.com/janishutz/BiogasControllerApp', new=2) + self.opened_web_browser_dialog.open() Builder.load_file('./gui/about/about.kv') diff --git a/gui/credits/credits.kv b/gui/credits/credits.kv deleted file mode 100644 index a5b732c..0000000 --- a/gui/credits/credits.kv +++ /dev/null @@ -1,27 +0,0 @@ -: - name: "credits" - canvas.before: - Color: - rgba: (20,20,20,0.2) - Rectangle: - size: self.size - pos: self.pos - FloatLayout: - Button: - text: "back" - size_hint: 0.4, 0.2 - pos_hint: {"x":0.3, "y":0.1} - on_release: - app.root.current = "about" - root.manager.transition.direction = "right" - GridLayout: - cols:1 - pos_hint:{"x":0.05, "y":0.35} - size_hint: 0.9, 0.5 - Label: - text: "This is a controller sofware that helps you reprogram and monitor the micro-controller used in ENATECH at KSWO" - Label: - text: "Written by: Janis Hutz\nDesigned by: Janis Hutz\nDesign language: Kivy" - Label: - text: "This software is free Software licensed under the GPL V3 (GNU General Public License) and as such comes with absolutely no warranty. In return, you can use, modify, distribute or use any of the code of this software in your own project, if you reuse the same license. For more infos, you can find a copy of this license in the project folder." - text_size: self.width, None diff --git a/gui/credits/credits.py b/gui/credits/credits.py deleted file mode 100644 index 02d341e..0000000 --- a/gui/credits/credits.py +++ /dev/null @@ -1,8 +0,0 @@ -from kivymd.uix.screen import MDScreen -from kivy.lang import Builder - - -class CreditsScreen(MDScreen): - pass - -Builder.load_file('./gui/credits/credits.kv') diff --git a/gui/home/home.kv b/gui/home/home.kv index 962b7bc..8448a77 100644 --- a/gui/home/home.kv +++ b/gui/home/home.kv @@ -1,19 +1,13 @@ : name: "home" - MDGridLayout: - cols:1 - MDFloatLayout: - MDCard: - radius: 36 - pos_hint: {"center_x": .5, "center_y": .5} - size_hint: .6, .8 - - Image: - source: "BiogasControllerAppLogo.png" - pos_hint: {"top": 1} - radius: 36, 36, 0, 0 - allow_stretch: True - keep_ratio: True + MDFloatLayout: + Image: + source: "BiogasControllerAppLogo.png" + pos_hint: {"top": 0.9} + size_hint_y: .3 + radius: 36, 36, 0, 0 + allow_stretch: True + keep_ratio: True MDGridLayout: cols: 1 @@ -28,32 +22,41 @@ pos_hint: {'center_x': 0, 'center_y': 0} - MDFloatLayout: - MDGridLayout: - cols: 2 - size_hint: 0.2, 0.2 - pos_hint: {"x": 0.4, "y": 0.3} + MDGridLayout: + spacing: 20 + size_hint: None, None + size: self.minimum_size + cols: 2 + pos_hint: {'center_x': 0.5, 'center_y': 0.3 } + MDFillRoundFlatButton: + font_size: 30 + text: "Start" + on_release: root.start() - MDRaisedButton: - on_release: root.start() - font_size: 30 - text: "Start" - radius: [25] - MDRaisedButton: - text: "Quit" - font_size: 30 - on_release: - root.quit() + MDFillRoundFlatButton: + text: "Quit" + font_size: 30 + pos_hint: {"x": 0.7, "center_y": 0} + on_release: root.quit() - MDLabel: - text: "You are running version V3.0.1" - font_size: 13 - pos_hint: {"y": -0.45, "x":0} - halign: 'center' - MDFlatButton: - text: "About" - font_size: 13 - size_hint: 0.07, 0.06 - pos_hint: {"x":0.01, "y":0.01} - on_release: - root.to_about() + MDLabel: + text: "You are running version V3.1.0" + font_size: 13 + pos_hint: {"y": -0.45, "x":0} + halign: 'center' + + MDFlatButton: + text: "About" + font_size: 13 + size_hint: 0.07, 0.06 + pos_hint: {"x":0.01, "y":0.01} + on_release: + root.to_about() + + # MDFlatButton: + # text: "Change Theme" + # font_size: 13 + # size_hint: 0.07, 0.06 + # pos_hint: {"right":0.99, "y":0.01} + # on_release: + # app.change_theme() diff --git a/gui/home/home.py b/gui/home/home.py index ff8ceb4..bd9fb2c 100644 --- a/gui/home/home.py +++ b/gui/home/home.py @@ -1,6 +1,8 @@ +from kivymd.app import MDApp +from kivymd.uix.button import MDFlatButton +from kivymd.uix.dialog import MDDialog from kivymd.uix.screen import MDScreen from kivy.lang import Builder -from gui.popups.popups import DualRowPopup, QuitPopup, TwoActionPopup from lib.com import ComSuperClass import platform @@ -10,62 +12,113 @@ information = { "Windows": { "2": "Un- and replug the cable and ensure you have the required driver(s) installed", "13": "You are probably missing a required driver or your cable doesn't work. Consult the wiki for more information", - "NO_COM": "Could not find a microcontroller. Please ensure you have one connected and the required driver(s) installed" + "NO_COM": "Could not find a microcontroller. Please ensure you have one connected and the required driver(s) installed", }, "Linux": { "2": "Un- and replug the cable, or if you haven't plugged a controller in yet, do that", "13": "Incorrect permissions at /dev/ttyUSB0. Open a terminal and type: sudo chmod 777 /dev/ttyUSB0", - "NO_COM": "Could not find a microcontroller. Please ensure you have one connected" - } + "NO_COM": "Could not find a microcontroller. Please ensure you have one connected", + }, } # This is the launch screen, i.e. what you see when you start up the app class HomeScreen(MDScreen): def __init__(self, com: ComSuperClass, **kw): - self._com = com; + self._com = com + self.connection_error_dialog = MDDialog( + title="Connection", + text="Failed to connect. See Details for more information and troubleshooting guide", + buttons=[ + MDFlatButton( + text="Cancel", + on_release=lambda _: self.connection_error_dialog.dismiss(), + ), + MDFlatButton( + text="Details", on_release=lambda _: self.open_details_popup() + ), + ], + ) + + self.quit_dialog = MDDialog( + title="Exit BiogasControllerApp", + text="Do you really want to exit BiogasControllerApp?", + buttons=[ + MDFlatButton( + text="Cancel", + on_release=lambda _: self.quit_dialog.dismiss(), + ), + MDFlatButton( + text="Quit", on_release=lambda _: self._quit() + ), + ], + ) super().__init__(**kw) - # Go to the main screen if we can establish connection or the check was disabled + def _quit(self): + self._com.close() + MDApp.get_running_app().stop() + + # Go to the main screen if we can establish connection or the check was disabled # in the configs def start(self): if self._com.connect(): - self.manager.current = 'main' - self.manager.transition.direction = 'right' + self.manager.current = "main" + self.manager.transition.direction = "right" else: - TwoActionPopup().open('Failed to connect', 'Details', self.open_details_popup) - print('ERROR connecting') + self.connection_error_dialog.open() + print("[ COM ] Connection failed!") # Open popup for details as to why the connection failed def open_details_popup(self): - DualRowPopup().open("Troubleshooting tips", self._generate_help()) + self.connection_error_dialog.dismiss() + self.details_dialog = MDDialog( + title="Troubleshooting", + text=self._generate_help(), + buttons=[ + MDFlatButton( + text="Ok", on_release=lambda _: self.details_dialog.dismiss() + ) + ], + ) + self.details_dialog.open() def _generate_help(self) -> str: operating_system = platform.system() if operating_system == "Windows" or operating_system == "Linux": - port = self._com.get_comport(); - information["Linux"]["13"] = f"Incorrect permissions at {port}. Resolve by running 'sudo chmod 777 {port}'" + port = self._com.get_comport() + if port == "Sim": + return "Running in simulator, so this error is just simulated" + + information["Linux"][ + "13" + ] = f"Incorrect permissions at {port}. Resolve by running 'sudo chmod 777 {port}'" + + if port == "": return information[operating_system]["NO_COM"] + err = self._com.get_error() if err != None: return information[operating_system][str(err.errno)] else: return "No error message available" else: - return "You are running on an unsupported Operating System. No help available" + return ( + "You are running on an unsupported Operating System. No help available" + ) # Helper to open a Popup to ask user whether to quit or not def quit(self): - QuitPopup(self._com).open() + self.quit_dialog.open() # Switch to about screen def to_about(self): - self.manager.current = 'about' - self.manager.transition.direction = 'down' + self.manager.current = "about" + self.manager.transition.direction = "down" # Load the design file for this screen (.kv files) -# The path has to be relative to root of the app, i.e. where the biogascontrollerapp.py +# The path has to be relative to root of the app, i.e. where the biogascontrollerapp.py # file is located -Builder.load_file('./gui/home/home.kv') +Builder.load_file("./gui/home/home.kv") diff --git a/gui/main/main.kv b/gui/main/main.kv index 6623a61..56dc4f4 100644 --- a/gui/main/main.kv +++ b/gui/main/main.kv @@ -1,101 +1,124 @@ : on_pre_enter: root.reset() name: "main" - canvas.before: - Color: - rgba: (10,10,10,0.1) - Rectangle: - size: self.size - pos: self.pos - GridLayout: - FloatLayout: - Label: - pos_hint: {"y":0.4} - text: "READOUT" - font_size: 40 - color: (0, 113, 0, 1) - bold: True - GridLayout: + MDFloatLayout: + MDGridLayout: + cols: 1 + pos_hint: {'x': 0, 'y': 0.4} + MDLabel: + text: "READOUT" + font_size: 40 + halign: 'center' + valign: 'center' + pos_hint: {'center_x': 0, 'center_y': 0} + bold: True + + MDGridLayout: cols:4 size_hint: 0.8, 0.3 pos_hint: {"x":0.1, "y":0.4} - Label: + MDLabel: text: "Sensor 1: " font_size: 20 - Label: + MDLabel: id: sensor1 text: "" size_hint: 1, 1 halign: 'left' text_size: self.size - Label: + MDLabel: text: "Sensor 2: " font_size: 20 - Label: + MDLabel: id: sensor2 text: "" size_hint: 1, 1 halign: 'left' text_size: self.size - Label: + MDLabel: text: "Sensor 3: " font_size: 20 - Label: + MDLabel: id: sensor3 text: "" size_hint: 1, 1 halign: 'left' text_size: self.size - Label: + MDLabel: text: "Sensor 4: " font_size: 20 - Label: + MDLabel: id: sensor4 text: "" size_hint: 1, 1 halign: 'left' text_size: self.size - Button: + MDFillRoundFlatButton: text: "Connect" - size_hint: 0.2, 0.1 - pos_hint: {"x": 0.5, "y": 0.05} - background_color: (255, 0, 0, 0.6) + size_hint: 0.15, 0.09 + pos_hint: {"x": 0.03, "y": 0.05} on_release: root.start() - Button: + + MDFillRoundFlatButton: text: "Disconnect" - size_hint: 0.2, 0.1 - pos_hint: {"x": 0.7, "y": 0.05} - background_color: (255, 0, 0, 0.6) + size_hint: 0.15, 0.09 + pos_hint: {"x": 0.2, "y": 0.05} on_release: root.end() - Button: + + MDFillRoundFlatButton: text: "Back" - size_hint: 0.3, 0.1 - pos_hint: {"x":0.05, "y":0.05} - background_color: (255, 0, 0, 0.6) + size_hint: 0.15, 0.09 + pos_hint: {"right": 0.95, "y":0.05} + md_bg_color: app.theme_cls.primary_dark on_release: root.end() app.root.current = "home" root.manager.transition.direction = "left" - ToggleButton: - id: mode_selector + + MDGridLayout: + cols: 2 size_hint: 0.15, 0.1 - pos_hint: {"x":0.1, "y":0.2} - text: "Normal Mode" if self.state == "normal" else "Fast Mode" - on_text: root.switch_mode(mode_selector.text) - background_color: (255,0,0,0.6) if self.state == "normal" else (0,0,255,0.6) - Button: + pos_hint: {"x":0.1, "y":0.15} + MDLabel: + text: "Fast Mode" + valign: "center" + MDSwitch: + id: mode_selector + on_active: root.switch_mode() + icon_active: "check" + + MDFillRoundFlatButton: text: "Configuration" - size_hint: 0.15, 0.1 - pos_hint: {"x":0.7, "y":0.2} - background_color: (255, 0, 0, 0.6) + size_hint: 0.1, 0.07 + pos_hint: {"x":0.45, "y":0.06} + md_bg_color: app.theme_cls.accent_dark on_release: root.end() app.root.current = "program" root.manager.transition.direction = "down" - Label: - id: status - text: "Status will appear here" - font_size: 10 - pos_hint: {"x":0.4, "y": 0.3} + + MDGridLayout: + size_hint: 0.2, None + spacing: 0 + padding: 0 + cols: 1 + pos_hint: {'right': 0.95, 'top': 0.95} + MDLabel: + id: status + text: "Status will appear here" + font_size: 10 + halign: 'right' + + MDGridLayout: + size_hint: None, None + spacing: 0 + padding: 0 + cols: 1 + pos_hint: {'right': 0.95, 'top': 0.925} + MDLabel: + id: port + text: "Port: Not connected" + font_size: 10 + halign: 'right' diff --git a/gui/main/main.py b/gui/main/main.py index 5d1f549..9e7715c 100644 --- a/gui/main/main.py +++ b/gui/main/main.py @@ -3,8 +3,9 @@ from time import time from typing import List, override from kivymd.uix.screen import MDScreen from kivy.lang import Builder -from gui.popups.popups import SingleRowPopup, TwoActionPopup, empty_func from kivy.clock import Clock, ClockEvent +from kivymd.uix.button import MDFlatButton +from kivymd.uix.dialog import MDDialog import queue import threading @@ -53,7 +54,7 @@ class ReaderThread(threading.Thread): # Hook to output stream if self._instructions.hook_main(): # We are now hooked to the stream (i.e. data is synced) - synced_queue.put(["HOOK"]) + synced_queue.put(["HOOK", self._com.get_comport()]) # making it exit using the stop function while self._run: @@ -70,17 +71,22 @@ class ReaderThread(threading.Thread): for i in range(4): # The slicing that happens here uses offsets automatically calculated from the sensor id # This allows for short code - data.append( - f"Tadc: { - self._decoder.decode_int(received[12 * i:12 * i + 4]) - }\nTemp: { - round(self._decoder.decode_float(received[12 * i + 5:12 * i + 11]) * 1000) / 1000 - }°C\nDC: { - round((self._decoder.decode_float_long(received[48 + 5 * i: 52 + 5 * i]) / 65535.0 * 100) * 1000) / 1000 - }%" - ) + try: + data.append( + f"Tadc: { + self._decoder.decode_int(received[12 * i:12 * i + 4]) + }\nTemp: { + round(self._decoder.decode_float(received[12 * i + 5:12 * i + 11]) * 1000) / 1000 + }°C\nDC: { + round((self._decoder.decode_float_long(received[48 + 5 * i: 52 + 5 * i]) / 65535.0 * 100) * 1000) / 1000 + }%" + ) + except: + data.append("Bad data") # Calculate the frequency of updates - data.append(str(round((1 / (time() - start_time)) * 1000) / 1000) + " Hz") + data.append( + str(round((1 / (time() - start_time)) * 1000) / 1000) + " Hz" + ) synced_queue.put(data) else: # Send error message to the UI updater @@ -104,6 +110,30 @@ class MainScreen(MDScreen): # Set some variables self._com = com self._event = None + self._fast_mode = False + + self.connection_error_dialog = MDDialog( + title="Connection", + text="Failed to connect. Do you wish to retry?", + buttons=[ + MDFlatButton( + text="Cancel", + on_release=lambda _: self.connection_error_dialog.dismiss(), + ), + MDFlatButton(text="Retry", on_release=lambda _: self.start()), + ], + ) + + self.mode_switch_error_dialog = MDDialog( + title="Mode Switch", + text="Failed to change mode. Please try again", + buttons=[ + MDFlatButton( + text="Ok", + on_release=lambda _: self.mode_switch_error_dialog.dismiss(), + ), + ], + ) # Prepare the reader thread self._prepare_reader() @@ -115,36 +145,31 @@ class MainScreen(MDScreen): def _prepare_reader(self): self._reader = ReaderThread() - self._reader.setDaemon(True) + self._reader.daemon = True self._reader.set_com(self._com) # Start the connection to the micro-controller to read data from it. # This also now starts the reader thread to continuously read out data def start(self): # Prevent running multiple times + self.connection_error_dialog.dismiss() if self._has_connected: return self.ids.status.text = "Connecting..." if self._com.connect(): - print("Acquired connection") + print("[ COM ] Connection Acquired") self._has_connected = True self._has_run = True if self._has_run: self._prepare_reader() # Start communication self._reader.start() - print("Reader has started") + print("[ COM ] Reader has started") self._event = Clock.schedule_interval(self._update_screen, 0.5) else: self.ids.status.text = "Connection failed" - TwoActionPopup().open( - "Failed to connect. Do you want to retry?", - "Cancel", - empty_func, - "Retry", - self.start, - ) + self.connection_error_dialog.open() # End connection to micro-controller and set it back to normal mode def end(self, set_msg: bool = True): @@ -166,10 +191,12 @@ class MainScreen(MDScreen): self._com.close() if set_msg: self.ids.status.text = "Connection terminated" + self.ids.port.text = "Port: Not connected" + self._has_connected = False print("Connection terminated") # A helper function to update the screen. Is called on an interval - def _update_screen(self, dt): + def _update_screen(self, _): update = [] try: update = synced_queue.get_nowait() @@ -182,8 +209,10 @@ class MainScreen(MDScreen): if update[0] == "ERR_HOOK": self.ids.status.text = "Hook failed" self.end(False) - elif update[0] == "HOOK": + if len(update) == 2: + if update[0] == "HOOK": self.ids.status.text = "Connected to controller" + self.ids.port.text = "Port: " + update[1] else: self.ids.sensor1.text = update[0] self.ids.sensor2.text = update[1] @@ -198,9 +227,10 @@ class MainScreen(MDScreen): self.ids.sensor3.text = "" self.ids.sensor4.text = "" self.ids.status.text = "Status will appear here" + self.ids.port.text = "Port: Not connected" # Switch the mode for the micro-controller - def switch_mode(self, new_mode: str): + def switch_mode(self): # Store if we have been connected to the micro-controller before mode was switched was_connected = self._has_connected @@ -210,12 +240,12 @@ class MainScreen(MDScreen): # Try to set the new mode try: - if new_mode == "Normal Mode": + if self._fast_mode: self._com.send("NM") else: self._com.send("FM") except: - SingleRowPopup().open("Failed to switch modes") + self.mode_switch_error_dialog.open() return self.ids.status.text = "Mode set" diff --git a/gui/popups/popups.kv b/gui/popups/popups.kv deleted file mode 100644 index 1d212e1..0000000 --- a/gui/popups/popups.kv +++ /dev/null @@ -1,111 +0,0 @@ -: - title: "BiogasControllerApp" - font_size: 50 - size_hint: 0.5, 0.4 - auto_dismiss: False - GridLayout: - cols: 1 - Label: - text: "Are you sure you want to leave?" - font_size: 20 - GridLayout: - cols:2 - Button: - text: "Yes" - font_size: 15 - on_release: - root.quit() - app.stop() - Button: - text: "No" - font_size: 15 - on_press: - root.dismiss() - -: - title: "INFORMATION" - size_hint: 0.7, 0.5 - auto_dismiss: True - GridLayout: - cols: 1 - Label: - id: msg - text: "Message" - text_size: self.width, None - halign: 'center' - GridLayout: - cols: 1 - Button: - text: "Ok" - on_release: - root.dismiss() - -: - title: "WARNING!" - font_size: 50 - size_hint: 0.5, 0.4 - auto_dismiss: False - GridLayout: - cols:1 - Label: - id: msg - text: "Message" - font_size: 20 - halign: 'center' - GridLayout: - cols:2 - Button: - id: btn1 - text: "Details" - on_release: - root.action_one() - root.dismiss() - Button: - id: btn2 - text:"Ok" - on_release: - root.action_two() - root.dismiss() - -: - title: "Details" - font_size: 50 - size_hint: 0.7, 0.6 - auto_dismiss: False - GridLayout: - cols:1 - Label: - id: msg_title - text: "Message title" - font_size: 20 - Label: - id: msg_body - text: "Message body" - font_size: 14 - Button: - text:"Ok" - on_release: - root.dismiss() - -: - title: "DETAILS" - font_size: 50 - size_hint: 1, 0.7 - auto_dismiss: False - GridLayout: - cols: 1 - Label: - id: msg_title - text: "title" - font_size: 20 - Label: - id: msg_body - text: "Message" - font_size: 13 - Label: - text: msg_extra - font_size: 13 - Button: - text:"Ok" - on_release: - root.dismiss() diff --git a/gui/popups/popups.py b/gui/popups/popups.py deleted file mode 100644 index f366274..0000000 --- a/gui/popups/popups.py +++ /dev/null @@ -1,63 +0,0 @@ -from typing import Callable -from kivy.uix.popup import Popup -from kivy.lang import Builder - -from lib.com import ComSuperClass - - -# Just an empty function -def empty_func(): - pass - -# ╭────────────────────────────────────────────────╮ -# │ Popups │ -# ╰────────────────────────────────────────────────╯ -# Below, you can find various popups with various designs that can be used in the app -class QuitPopup(Popup): - def __init__(self, com: ComSuperClass, **kw): - self._com = com; - super().__init__(**kw) - - def quit(self): - self._com.close() - -class SingleRowPopup(Popup): - def open(self, message, *_args, **kwargs): - self.ids.msg.text = message - return super().open(*_args, **kwargs) - -class DualRowPopup(Popup): - def open(self, title: str, message: str, *_args, **kwargs): - self.ids.msg_title.text = title - self.ids.msg_body.text = message - return super().open(*_args, **kwargs) - -class LargeTrippleRowPopup(Popup): - def open(self, title: str, message: str, details: str, *_args, **kwargs): - self.ids.msg_title.text = title - self.ids.msg_body.text = message - self.ids.msg_extra.text = details - return super().open(*_args, **kwargs) - -class TwoActionPopup(Popup): - def open(self, - message: str, - button_one: str, - action_one: Callable[[], None], - button_two: str = 'Ok', - action_two: Callable[[], None] = empty_func, - *_args, - **kwargs - ): - self.ids.msg.text = message - self.ids.btn1.text = button_one - self.ids.btn2.text = button_two - self.action_one = action_one - self.action_two = action_two - return super().open(*_args, **kwargs) - - -# Load the design file for this screen (.kv files) -# The path has to be relative to root of the app, i.e. where the biogascontrollerapp.py -# file is located -Builder.load_file('./gui/popups/popups.kv') diff --git a/gui/program/program.kv b/gui/program/program.kv index 23e38fa..1299da7 100644 --- a/gui/program/program.kv +++ b/gui/program/program.kv @@ -1,126 +1,123 @@ : name: "program" on_enter: self.config_loader = root.load_config() - md_bg_color: app.theme_cls.primary_color FloatLayout: - Label: - text: "Configuration" - font_size: 40 - color: (0, 113, 0, 1) - bold: True - pos_hint: {"y":0.4} - GridLayout: - size_hint: 0.8, 0.5 - pos_hint: {"x":0.1, "y":0.2} + MDGridLayout: + cols: 1 + pos_hint: {'x': 0, 'y': 0.4} + MDLabel: + text: "Configuration" + font_size: 40 + halign: 'center' + valign: 'center' + pos_hint: {'center_x': 0, 'center_y': 0} + bold: True + + MDGridLayout: + cols: 1 + pos_hint: {'x': 0, 'y': 0.33} + MDLabel: + text: "Change the configuration of the microcontroller" + font_size: 18 + halign: 'center' + valign: 'center' + pos_hint: {'center_x': 0, 'center_y': 0} + italic: True + + MDGridLayout: + cols: 1 + pos_hint: {'x': 0, 'y': 0.25} + MDLabel: + id: status + text: "Loading..." + font_size: 17 + halign: 'center' + bold: True + + MDGridLayout: + size_hint: 0.9, 0.5 + spacing: 10 + pos_hint: {"x":0.05, "y":0.2} cols: 4 - Label: - text: "Sensor 1, a:" - TextInput: + MDTextField: id: s1_a - multiline: False - input_filter: "float" - Label: - text: "Sensor 1, b:" - TextInput: + hint_text: 'Sensor 1 a' + on_text: root.validate_float(self) + MDTextField: id: s1_b - multiline: False - input_filter: "float" - Label: - text: "Sensor 1, c:" - TextInput: + hint_text: 'Sensor 1 b' + on_text: root.validate_float(self) + MDTextField: id: s1_c - multiline: False - input_filter: "float" - Label: - text: "Sensor 1, Temp:" - TextInput: + hint_text: 'Sensor 1 c' + on_text: root.validate_float(self) + MDTextField: id: s1_t - multiline: False - input_filter: "float" - Label: - text: "Sensor 2, a:" - TextInput: + hint_text: 'Sensor 1 Temperature' + on_text: root.validate_float(self) + + MDTextField: id: s2_a - multiline: False - input_filter: "float" - Label: - text: "Sensor 2, b:" - TextInput: + hint_text: 'Sensor 2 a' + on_text: root.validate_float(self) + MDTextField: id: s2_b - multiline: False - input_filter: "float" - Label: - text: "Sensor 2, c:" - TextInput: + hint_text: 'Sensor 2 b' + on_text: root.validate_float(self) + MDTextField: id: s2_c - multiline: False - input_filter: "float" - Label: - text: "Sensor 2, Temp:" - TextInput: + hint_text: 'Sensor 2 c' + on_text: root.validate_float(self) + MDTextField: id: s2_t - multiline: False - input_filter: "float" - Label: - text: "Sensor 3, a:" - TextInput: + hint_text: 'Sensor 2 Temperature' + on_text: root.validate_float(self) + + MDTextField: id: s3_a - multiline: False - input_filter: "float" - Label: - text: "Sensor 3, b:" - TextInput: + hint_text: 'Sensor 3 a' + on_text: root.validate_float(self) + MDTextField: id: s3_b - multiline: False - input_filter: "float" - Label: - text: "Sensor 3, c:" - TextInput: + hint_text: 'Sensor 3 b' + on_text: root.validate_float(self) + MDTextField: id: s3_c - multiline: False - input_filter: "float" - Label: - text: "Sensor 3, Temp:" - TextInput: + hint_text: 'Sensor 3 c' + on_text: root.validate_float(self) + MDTextField: id: s3_t - multiline: False - input_filter: "float" - Label: - text: "Sensor 4, a:" - TextInput: + hint_text: 'Sensor 3 Temperature' + on_text: root.validate_float(self) + + MDTextField: id: s4_a - multiline: False - input_filter: "float" - Label: - text: "Sensor 4, b:" - TextInput: + hint_text: 'Sensor 4 a' + on_text: root.validate_float(self) + MDTextField: id: s4_b - multiline: False - input_filter: "float" - Label: - text: "Sensor 4, c:" - TextInput: + hint_text: 'Sensor 4 b' + on_text: root.validate_float(self) + MDTextField: id: s4_c - multiline: False - input_filter: "float" - Label: - text: "Sensor 4, Temp:" - TextInput: + hint_text: 'Sensor 4 c' + on_text: root.validate_float(self) + MDTextField: id: s4_t - multiline: False - input_filter: "float" - Button: + hint_text: 'Sensor 4 Temperature' + on_text: root.validate_float(self) + + MDFillRoundFlatButton: + size_hint: 0.1, 0.07 text: "Back" - size_hint: 0.1, 0.1 pos_hint: {"x":0.1, "y":0.1} background_color: (255, 0, 0, 0.6) on_release: app.root.current = "main" root.manager.transition.direction = "up" - Button: + MDFillRoundFlatButton: + size_hint: 0.15, 0.09 text: "Save" - size_hint: 0.2, 0.1 pos_hint: {"x":0.6, "y":0.1} - background_color: (255, 0, 0, 0.6) on_release: root.save() diff --git a/gui/program/program.py b/gui/program/program.py index 2127619..73fb772 100644 --- a/gui/program/program.py +++ b/gui/program/program.py @@ -3,7 +3,8 @@ from kivymd.uix.screen import MDScreen from kivy.lang import Builder from lib.decoder import Decoder from lib.instructions import Instructions -from gui.popups.popups import SingleRowPopup, TwoActionPopup, empty_func +from kivymd.uix.button import MDFlatButton +from kivymd.uix.dialog import MDDialog from lib.com import ComSuperClass from kivy.clock import Clock @@ -18,13 +19,60 @@ class ProgramScreen(MDScreen): self._com = com self._instructions = Instructions(com) self._decoder = Decoder() + + self.connection_error_dialog = MDDialog( + title="Connection", + text="Failed to connect. Do you wish to retry?", + buttons=[ + MDFlatButton( + text="Cancel", + on_release=lambda dt: self.connection_error_dialog.dismiss(), + ), + MDFlatButton(text="Retry", on_release=lambda dt: self._load()), + ], + ) + + self.missing_fields_error_dialog = MDDialog( + title="Save", + text="Some fields are missing entries. Please fill them out and try again", + buttons=[ + MDFlatButton( + text="Ok", + on_release=lambda dt: self.missing_fields_error_dialog.dismiss(), + ), + ], + ) + + self.save_error_dialog = MDDialog( + title="Save", + text="Failed to save data. Please try again", + buttons=[ + MDFlatButton( + text="Ok", + on_release=lambda dt: self.save_error_dialog.dismiss(), + ), + ], + ) + + self.save_success_dialog = MDDialog( + title="Save", + text="Data saved successfully!", + buttons=[ + MDFlatButton( + text="Ok", + on_release=lambda dt: self.save_success_dialog.dismiss(), + ), + ], + ) + super().__init__(**kw) def load_config(self): - Clock.schedule_once(self._load) + Clock.schedule_once(lambda dt: self._load()) # Load the current configuration from the micro-controller - def _load(self, dt: float): + def _load(self): + self.ids.status.text = "Loading..." # Hook to the microcontroller's data stream (i.e. sync up with it) if self._instructions.hook("RD", ["\n", "R", "D", "\n"]): config: List[List[str]] = [] @@ -37,13 +85,7 @@ class ProgramScreen(MDScreen): received = self._com.receive(28) except: # Open error popup - TwoActionPopup().open( - "Failed to connect to micro-controller, retry?", - "Cancel", - empty_func, - "Retry", - lambda: self._load(0), - ) + self.connection_error_dialog.open() return # Create a list of strings to store the config for the sensor @@ -58,16 +100,11 @@ class ProgramScreen(MDScreen): # Add it to the config config.append(config_sensor_i) + self.ids.status.text = "" self._set_ui(config) else: - TwoActionPopup().open( - "Failed to connect to micro-controller, retry?", - "Cancel", - empty_func, - "Retry", - lambda: self._load(0), - ) + self.connection_error_dialog.open() # Set the elements of the UI to the values of the config def _set_ui(self, config: List[List[str]]): @@ -96,16 +133,37 @@ class ProgramScreen(MDScreen): # Transmit the changed data to the micro-controller to reconfigure it def save(self): + self.ids.status.text = "Saving..." data = self._read_ui() if data == None: - SingleRowPopup().open("Some fields are missing values!") + self.missing_fields_error_dialog() else: try: self._instructions.change_config(data) except Exception as e: - SingleRowPopup().open("Could not save data!") + self.save_error_dialog.open() return - SingleRowPopup().open("Data saved successfully") + self.save_success_dialog.open() + self.ids.status.text = "Saved!" + Clock.schedule_once(self.reset_update, 5) + + def reset_update(self, dt): + self.ids.status.text = "" + + def validate_float(self, instance): + text = instance.text + + # Allow only digits and one dot + if text.count(".") > 1 or any(c not in "0123456789." for c in text): + # Remove invalid characters + clean_text = "".join(c for c in text if c in "0123456789.") + # Remove extra dots + if clean_text.count(".") > 1: + first_dot = clean_text.find(".") + clean_text = clean_text[: first_dot + 1] + clean_text[ + first_dot + 1 : + ].replace(".", "") + instance.text = clean_text # Load the design file for this screen (.kv files) diff --git a/lib/com.py b/lib/com.py index f480857..54e2efd 100644 --- a/lib/com.py +++ b/lib/com.py @@ -6,16 +6,23 @@ import serial.tools.list_ports class ComSuperClass(ABC): - def __init__(self, baudrate: Optional[int] = 19200, filters: Optional[list[str]] = None) -> None: + def __init__( + self, baudrate: Optional[int] = 19200, filters: Optional[list[str]] = None + ) -> None: self._serial: Optional[serial.Serial] = None - self._filters = filters if filters != None else [ 'USB-Serial Controller', 'Prolific USB-Serial Controller' ] - self._port_override = '' + self._filters = ( + filters + if filters != None + else ["USB-Serial Controller", "Prolific USB-Serial Controller"] + ) + self._port_override = "" self._baudrate = baudrate if baudrate != None else 19200 self._err = None def set_port_override(self, override: str) -> None: """Set the port override, to disable port search""" - self._port_override = override + if override != "" and override != "None": + self._port_override = override def get_error(self) -> serial.SerialException | None: return self._err @@ -58,7 +65,7 @@ class Com(ComSuperClass): def get_comport(self) -> str: """Find the comport the microcontroller has attached to""" - if self._port_override != '': + if self._port_override != "": return self._port_override # Catch all errors and simply return an empty string if search unsuccessful @@ -80,7 +87,7 @@ class Com(ComSuperClass): comport = self.get_comport() # Comport search returns empty string if search unsuccessful - if comport == '': + if comport == "": try: self._serial = serial.Serial(comport, self._baudrate, timeout=5) except serial.SerialException as e: @@ -108,7 +115,7 @@ class Com(ComSuperClass): if self._serial != None: return self._serial.read(byte_count) else: - raise Exception('ERR_CONNECTING') + raise Exception("ERR_CONNECTING") def send(self, msg: str) -> None: """Send a string over serial connection. Will open a connection if none is available""" @@ -116,12 +123,12 @@ class Com(ComSuperClass): if self._serial != None: self._serial.write(msg.encode()) else: - raise Exception('ERR_CONNECTING') + raise Exception("ERR_CONNECTING") def send_float(self, msg: float) -> None: """Send a float number over serial connection""" self._connection_check() if self._serial != None: - self._serial.write(bytearray(struct.pack('>f', msg))[0:3]) + self._serial.write(bytearray(struct.pack(">f", msg))[0:3]) else: - raise Exception('ERR_CONNECTING') + raise Exception("ERR_CONNECTING") diff --git a/lib/instructions.py b/lib/instructions.py index f10fbf1..9ae3c8c 100644 --- a/lib/instructions.py +++ b/lib/instructions.py @@ -12,10 +12,6 @@ class Instructions: def __init__(self, com: ComSuperClass) -> None: self._com = com - # Set a port override (to use a specific COM port) - def set_port_override(self, override: str) -> None: - self._com.set_port_override(override) - # Helper method to hook to the data stream according to protocol. # You can specify the sequence that the program listens to to sync up, # as an array of strings, that should each be of length one and only contain diff --git a/lib/test/com.py b/lib/test/com.py index fded0ba..eca0d32 100644 --- a/lib/test/com.py +++ b/lib/test/com.py @@ -50,7 +50,7 @@ class SensorConfig: class Com(ComSuperClass): def __init__( - self, baudrate: int = 19200, filters: Optional[list[str]] = None + self, fail_sim: int, baudrate: int = 19200, filters: Optional[list[str]] = None ) -> None: # Calling the constructor of the super class to assign defaults print("\n\nWARNING: Using testing library for communication!\n\n") @@ -62,6 +62,7 @@ class Com(ComSuperClass): self.__reconf_sensor = 0 self.__reconf_step = 0 + self.__fail_sim = fail_sim self.__config: List[SensorConfig] = [ SensorConfig(), @@ -78,11 +79,11 @@ class Com(ComSuperClass): self._port_override = override def get_comport(self) -> str: - return "test" if self._port_override != "" else self._port_override + return "Sim" if self._port_override == "" else self._port_override def connect(self) -> bool: - # Randomly return false in 1 in 20 ish cases - if random.randint(0, 20) == 1: + # Randomly return false in 1 in fail_sim ish cases + if random.randint(0, self.__fail_sim) == 0: print("Simulating error to connect") return False return True