diff --git a/README.md b/README.md index 3bcf3cb..a20b32e 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,13 @@
- + +BiogasControllerApp has just received a major rewrite, where I focused on code-readability, documentation and stability. The documentation in the code is aimed at beginners and does contain some unnecessary extra comments + +If you are here to read the code, the files you are most likely looking for can be found in the `biogascontrollerapp/lib` folder. If you want to understand and have a look at all of the application, start with the `biogascontrollerapp.py` file in the `biogascontrollerapp` folder + +# Features + ***LOOKING FOR A MacOS BUILD MAINTAINER! You may follow the official build instructions on the kivy.org website. All other materials should already be included in this repository*** ## FEATURES diff --git a/biogascontrollerapp/biogascontrollerapp.py b/biogascontrollerapp/biogascontrollerapp.py index c2fc2c4..f3e4a2a 100644 --- a/biogascontrollerapp/biogascontrollerapp.py +++ b/biogascontrollerapp/biogascontrollerapp.py @@ -1,42 +1,61 @@ +# ──────────────────────────────────────────────────────────────────── +# ╭────────────────────────────────────────────────╮ +# │ BiogasControllerApp │ +# ╰────────────────────────────────────────────────╯ +# +# 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. +# It also uses the pyserial library for communication with the micro- +# controller with RS232 +# +# ──────────────────────────────────────────────────────────────────── + import os import configparser from typing import override from lib.com import Com + +# Load the config file config = configparser.ConfigParser() -config.read('./config.ini') +config.read("./config.ini") # Load config and disable kivy log if necessary -if config['Dev Settings']['verbose'] == "True": +if config["Dev Settings"]["verbose"] == "True": pass else: os.environ["KIVY_NO_CONSOLELOG"] = "1" -# Load kivy modules +# Load kivy modules. Kivy is the UI framework used. See https://kivy.org # from kivy.core.window import Window, Config from kivy.uix.screenmanager import ScreenManager from kivy.app import App -# Load other libraries -# import threading # Store the current app version app_version = f"{config['Info']['version']}{config['Info']['subVersion']}" -#---------# -# Screens # -#---------# +# ╭────────────────────────────────────────────────╮ +# │ Screens │ +# ╰────────────────────────────────────────────────╯ +# 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 # -#----------------# + + +# ╭────────────────────────────────────────────────╮ +# │ Screen Manager │ +# ╰────────────────────────────────────────────────╯ +# Kivy uses a screen manager to manage pages in the application class BiogasControllerApp(App): def __init__(self, **kwargs): super().__init__(**kwargs) @@ -44,14 +63,17 @@ class BiogasControllerApp(App): @override def build(self): - com = Com(); - self.icon = './BiogasControllerAppLogo.png' - self.title = 'BiogasControllerApp-' + app_version - self.screen_manager.add_widget(HomeScreen(com, name='home')) - self.screen_manager.add_widget(MainScreen(com, name='main')) - self.screen_manager.add_widget(CreditsScreen(name='credits')) - self.screen_manager.add_widget(AboutScreen(name='about')) + com = Com() + self.icon = "./BiogasControllerAppLogo.png" + self.title = "BiogasControllerApp-" + app_version + 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 + +# Disallow this file to be imported if __name__ == "__main__": BiogasControllerApp().run() diff --git a/biogascontrollerapp/config.ini b/biogascontrollerapp/config.ini index 467137c..f92e8a9 100644 --- a/biogascontrollerapp/config.ini +++ b/biogascontrollerapp/config.ini @@ -8,7 +8,7 @@ sizew = 800 [Dev Settings] verbose = True log_level = DEBUG -disableconnectioncheck = False +disableconnectioncheck = True [License] show = 1 diff --git a/biogascontrollerapp/gui/home/home.py b/biogascontrollerapp/gui/home/home.py index 87b01f1..6393e78 100644 --- a/biogascontrollerapp/gui/home/home.py +++ b/biogascontrollerapp/gui/home/home.py @@ -1,30 +1,49 @@ from kivy.uix.screenmanager import Screen from kivy.lang import Builder -from gui.popups.popups import QuitPopup, SingleRowPopup, TwoActionPopup +from gui.popups.popups import QuitPopup, TwoActionPopup from lib.com import Com +import configparser + +config = configparser.ConfigParser() +config.read('./config.ini') + +# This is the launch screen, i.e. what you see when you start up the app class HomeScreen(Screen): def __init__(self, com: Com, **kw): self._com = com; super().__init__(**kw) + # 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(19200): + if config[ 'Dev Settings' ][ 'disableconnectioncheck' ] != "True": + if self._com.connect(): + self.manager.current = 'main' + self.manager.transition.direction = 'right' + else: + TwoActionPopup().open('Failed to connect', 'Details', self.open_details_popup) + print('ERROR connecting') + else: self.manager.current = 'main' self.manager.transition.direction = 'right' - else: - TwoActionPopup().open('Failed to connect', 'Details', self.open_details_popup) - print('ERROR connecting') + # Open popup for details as to why the connection failed def open_details_popup(self): + # TODO: Finish print( 'Details' ) + # Helper to open a Popup to ask user whether to quit or not def quit(self): QuitPopup(self._com).open() + # Switch to about screen def to_about(self): 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 +# file is located Builder.load_file('./gui/home/home.kv') diff --git a/biogascontrollerapp/gui/main/main.kv b/biogascontrollerapp/gui/main/main.kv index 9e19e1d..4e714b4 100644 --- a/biogascontrollerapp/gui/main/main.kv +++ b/biogascontrollerapp/gui/main/main.kv @@ -74,25 +74,7 @@ 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: - text: "Read Data" - size_hint: 0.15, 0.1 - pos_hint: {"x":0.3, "y":0.2} - background_color: (255, 0, 0, 0.6) - on_release: - root.end() - app.root.current = "read" - root.manager.transition.direction = "down" - Button: - text: "Temperature" - size_hint: 0.15, 0.1 - pos_hint: {"x":0.5, "y":0.2} - background_color: (255, 0, 0, 0.6) - on_release: - root.end() - app.root.current = "temperature" - root.manager.transition.direction = "down" - Button: - text: "Change all Data" + text: "Configuration" size_hint: 0.15, 0.1 pos_hint: {"x":0.7, "y":0.2} background_color: (255, 0, 0, 0.6) @@ -101,7 +83,7 @@ app.root.current = "program" root.manager.transition.direction = "down" Label: - id: frequency - text: "Frequency will appear here" + id: status + text: "Status will appear here" font_size: 10 pos_hint: {"x":0.4, "y": 0.3} diff --git a/biogascontrollerapp/gui/main/main.py b/biogascontrollerapp/gui/main/main.py index ccb8679..ed25ce9 100644 --- a/biogascontrollerapp/gui/main/main.py +++ b/biogascontrollerapp/gui/main/main.py @@ -1,25 +1,200 @@ +from ctypes import ArgumentError +from time import time +from typing import List, override from kivy.uix.screenmanager import Screen from kivy.lang import Builder +from gui.popups.popups import SingleRowPopup, TwoActionPopup, empty_func +from kivy.clock import Clock, ClockEvent +import queue +import threading +# Load utilities +from lib.instructions import Instructions from lib.com import Com +from lib.decoder import Decoder +# TODO: Consider consolidating start and stop button + + +# Queue with data that is used to synchronize +synced_queue: queue.Queue[List[str]] = queue.Queue() + + +# ╭────────────────────────────────────────────────╮ +# │ Data Reading Thread Helper │ +# ╰────────────────────────────────────────────────╯ +# Using a Thread to run this in parallel to the UI to improve responsiveness +class ReaderThread(threading.Thread): + _com: Com + _decoder: Decoder + _instructions: Instructions + + # This method allows the user to set Com object to be used. + # The point of this is to allow for the use of a single Com object to not waste resources + def set_com(self, com: Com): + """Set the Com object to be used in this + + Args: + com: The com object to be used + """ + self._com = com + self._run = True + self._decoder = Decoder() + + # This method is given by the Thread class and has to be overriden to change + # what is executed when the thread starts + @override + def run(self) -> None: + self._run = True + if self._com == None: + raise ArgumentError("Com object not passed in (do using set_com)") + # Hook to output stream + if self._instructions.hook("", ["\n", " ", " ", " "]): + # We are now hooked to the stream (i.e. data is synced) + synced_queue.put(["HOOK"]) + + # making it exit using the stop function + while self._run: + # Take note of the time before reading the data to deduce frequency of updates + start_time = time() + + # We need to read 68 bytes of data, given by the program running on the controller + received = self._com.receive(68) + + # Store the data in a list of strings + data: List[str] = [] + + # For all sensors connected, execute the same thing + 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_float(received[12 * i:12 * i + 4]) + }\nTemperature: { + self._decoder.decode_float(received[12 * i + 5:12 * i + 11]) + }\nDuty-Cycle: { + self._decoder.decode_float_long(received[48 + 5 * i: 52 + 5 * i]) / 65535.0 * 100 + }%" + ) + # Calculate the frequency of updates + data.append(str(1 / (time() - start_time))) + else: + # Send error message to the UI updater + synced_queue.put(["ERR_HOOK"]) + return + + def stop(self) -> None: + self._run = False + + +# ╭────────────────────────────────────────────────╮ +# │ Main App Screen │ +# ╰────────────────────────────────────────────────╯ +# This is the main screen, where you can read out data class MainScreen(Screen): + _event: ClockEvent + + # The constructor if this class takes a Com object to share one between all screens + # to preserve resources and make handling better def __init__(self, com: Com, **kw): - self._com = com; + # Set some variables + self._com = com + self._event = None + + # Prepare the reader thread + self._reader = ReaderThread() + self._reader.setDaemon(True) + self._reader.set_com(com) + self._has_connected = False + + # Call the constructor for the Screen class super().__init__(**kw) + # 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): - pass + self.ids.status.text = "Connecting..." + if self._com.connect(): + self._has_connected = True + # Start communication + self._reader.start() + 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, + ) + # End connection to micro-controller and set it back to normal mode def end(self): - pass + # Set micro-controller back to Normal Mode when ending communication + # to make sure temperature control will work + if self._has_connected: + if self._event != None: + self._event.cancel() + self._reader.stop() + try: + self._com.send("NM") + except: + pass + self._com.close() + self.ids.status.text = "Connection terminated" + # A helper function to update the screen. Is called on an interval + def _update_screen(self): + update = synced_queue.get() + if len(update) == 1: + if update[0] == "ERR_HOOK": + self.ids.status.text = "Hook failed" + self.end() + elif update[0] == "HOOK": + self.ids.status.text = "Connected to controller" + else: + self.ids.sensor1.text = update[0] + self.ids.sensor2.text = update[1] + self.ids.sensor3.text = update[2] + self.ids.sensor4.text = update[3] + self.ids.status.text = "Connected, f = " + update[4] + + # Reset the screen when the screen is entered def reset(self): - pass + self.ids.sensor1.text = "" + self.ids.sensor2.text = "" + self.ids.sensor3.text = "" + self.ids.sensor4.text = "" + self.ids.status.text = "Status will appear here" - def back(self): - pass + # Switch the mode for the micro-controller + def switch_mode(self, new_mode: str): + # Store if we have been connected to the micro-controller before mode was switched + was_connected = self._reader.is_alive + + # Disconnect from the micro-controller + self.end() + self.ids.status.text = "Setting mode..." + + # Try to set the new mode + try: + if new_mode == "Normal Mode": + self._com.send("NM") + else: + self._com.send("FM") + except: + SingleRowPopup().open("Failed to switch modes") + return + + # If we have been connected, reconnect + if was_connected: + self.start() -Builder.load_file('./gui/main/main.kv') +# 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/main/main.kv") diff --git a/biogascontrollerapp/gui/popups/popups.py b/biogascontrollerapp/gui/popups/popups.py index 41d5d16..350d2de 100644 --- a/biogascontrollerapp/gui/popups/popups.py +++ b/biogascontrollerapp/gui/popups/popups.py @@ -4,9 +4,15 @@ from kivy.lang import Builder from lib.com import Com + +# 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: Com, **kw): self._com = com; @@ -50,4 +56,8 @@ class TwoActionPopup(Popup): 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/biogascontrollerapp/gui/program/program.kv b/biogascontrollerapp/gui/program/program.kv index e69de29..7d61e0d 100644 --- a/biogascontrollerapp/gui/program/program.kv +++ b/biogascontrollerapp/gui/program/program.kv @@ -0,0 +1,131 @@ +