Restructure for better usability

This commit is contained in:
2025-06-10 17:15:39 +02:00
parent af4b697e01
commit e423add6a0
23 changed files with 0 additions and 0 deletions

3
gui/PopupManager.py Normal file
View File

@@ -0,0 +1,3 @@
from gui.popups.popups import *

37
gui/about/about.kv Normal file
View File

@@ -0,0 +1,37 @@
<AboutScreen>:
name: "about"
canvas.before:
Color:
rgba: (10,10,10,0.1)
Rectangle:
size: self.size
pos: self.pos
GridLayout:
cols: 1
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"

13
gui/about/about.py Normal file
View File

@@ -0,0 +1,13 @@
from kivy.uix.screenmanager import Screen
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)
Builder.load_file('./gui/about/about.kv')

27
gui/credits/credits.kv Normal file
View File

@@ -0,0 +1,27 @@
<CreditsScreen>:
name: "credits"
canvas.before:
Color:
rgba: (10,10,10,0.1)
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

8
gui/credits/credits.py Normal file
View File

@@ -0,0 +1,8 @@
from kivy.uix.screenmanager import Screen
from kivy.lang import Builder
class CreditsScreen(Screen):
pass
Builder.load_file('./gui/credits/credits.kv')

45
gui/home/home.kv Normal file
View File

@@ -0,0 +1,45 @@
<HomeScreen>:
name: "home"
canvas.before:
Color:
rgba: (10,10,10,0.1)
Rectangle:
size: self.size
pos: self.pos
GridLayout:
cols:1
Label:
text: "BiogasanlageControllerApp"
font_size: 50
color: (0, 113, 0, 1)
bold:True
italic:True
FloatLayout:
GridLayout:
cols: 2
size_hint: 0.8, 0.8
pos_hint: {"x": 0.1, "y": 0.1}
Button:
text: "Start"
background_color: (255, 0, 0, 0.6)
font_size: 30
on_release:
root.start()
Button:
text: "Quit"
background_color: (255, 0, 0, 0.6)
font_size: 30
on_release:
root.quit()
Label:
text: "You are running version V3.0.0"
font_size: 13
pos_hint: {"y": -0.45, "x":0.05}
Button:
text: "About"
font_size: 13
size_hint: 0.07, 0.06
pos_hint: {"x":0.01, "y":0.01}
background_color: (50, 0, 0, 0.2)
on_release:
root.to_about()

71
gui/home/home.py Normal file
View File

@@ -0,0 +1,71 @@
from kivy.uix.screenmanager import Screen
from kivy.lang import Builder
from gui.popups.popups import DualRowPopup, QuitPopup, TwoActionPopup
from lib.com import ComSuperClass
import platform
# Information for errors encountered when using pyserial
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"
},
"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"
}
}
# This is the launch screen, i.e. what you see when you start up the app
class HomeScreen(Screen):
def __init__(self, com: ComSuperClass, **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():
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):
DualRowPopup().open("Troubleshooting tips", self._generate_help())
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}'"
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"
# 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')

89
gui/main/main.kv Normal file
View File

@@ -0,0 +1,89 @@
<MainScreen>:
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:
cols:4
size_hint: 0.8, 0.3
pos_hint: {"x":0.1, "y":0.4}
Label:
text: "Sensor 1: "
font_size: 20
Label:
id: sensor1
text: ""
Label:
text: "Sensor 2: "
font_size: 20
Label:
id: sensor2
text: ""
Label:
text: "Sensor 3: "
font_size: 20
Label:
id: sensor3
text: ""
Label:
text: "Sensor 4: "
font_size: 20
Label:
id: sensor4
text: ""
Button:
text: "Connect"
size_hint: 0.2, 0.1
pos_hint: {"x": 0.5, "y": 0.05}
background_color: (255, 0, 0, 0.6)
on_release:
root.start()
Button:
text: "Disconnect"
size_hint: 0.2, 0.1
pos_hint: {"x": 0.7, "y": 0.05}
background_color: (255, 0, 0, 0.6)
on_release:
root.end()
Button:
text: "Back"
size_hint: 0.3, 0.1
pos_hint: {"x":0.05, "y":0.05}
background_color: (255, 0, 0, 0.6)
on_release:
root.end()
app.root.current = "home"
root.manager.transition.direction = "left"
ToggleButton:
id: mode_selector
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:
text: "Configuration"
size_hint: 0.15, 0.1
pos_hint: {"x":0.7, "y":0.2}
background_color: (255, 0, 0, 0.6)
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}

212
gui/main/main.py Normal file
View File

@@ -0,0 +1,212 @@
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 ComSuperClass
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: ComSuperClass
_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: ComSuperClass):
"""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()
self._instructions = Instructions(com)
# 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_main():
# 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_int(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: ComSuperClass, **kw):
# 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):
self.ids.status.text = "Connecting..."
if self._com.connect():
print("Acquired connection")
self._has_connected = True
# Start communication
self._reader.start()
print("Reader has started")
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, set_msg: bool = True):
# 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()
if set_msg:
self.ids.status.text = "Connection terminated"
print("Connection terminated")
# A helper function to update the screen. Is called on an interval
def _update_screen(self, dt):
update = []
try:
update = synced_queue.get_nowait()
except:
pass
if len(update) == 0:
# There are no updates to process, don't block and simply try again next time
return
if len(update) == 1:
if update[0] == "ERR_HOOK":
self.ids.status.text = "Hook failed"
self.end(False)
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):
self.ids.sensor1.text = ""
self.ids.sensor2.text = ""
self.ids.sensor3.text = ""
self.ids.sensor4.text = ""
self.ids.status.text = "Status will appear here"
# 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()
# 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")

111
gui/popups/popups.kv Normal file
View File

@@ -0,0 +1,111 @@
<QuitPopup>:
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()
<SingleRowPopup>:
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()
<TwoActionPopup>:
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()
<DualRowPopup>:
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()
<LargeTrippleRowPopUp>:
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()

63
gui/popups/popups.py Normal file
View File

@@ -0,0 +1,63 @@
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')

131
gui/program/program.kv Normal file
View File

@@ -0,0 +1,131 @@
<ProgramScreen>:
name: "program"
on_enter: self.config_loader = root.load_config()
canvas.before:
Color:
rgba: (10,10,10,0.1)
Rectangle:
size: self.size
pos: self.pos
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}
cols: 4
Label:
text: "Sensor 1, a:"
TextInput:
id: s1_a
multiline: False
input_filter: "float"
Label:
text: "Sensor 1, b:"
TextInput:
id: s1_b
multiline: False
input_filter: "float"
Label:
text: "Sensor 1, c:"
TextInput:
id: s1_c
multiline: False
input_filter: "float"
Label:
text: "Sensor 1, Temp:"
TextInput:
id: s1_t
multiline: False
input_filter: "float"
Label:
text: "Sensor 2, a:"
TextInput:
id: s2_a
multiline: False
input_filter: "float"
Label:
text: "Sensor 2, b:"
TextInput:
id: s2_b
multiline: False
input_filter: "float"
Label:
text: "Sensor 2, c:"
TextInput:
id: s2_c
multiline: False
input_filter: "float"
Label:
text: "Sensor 2, Temp:"
TextInput:
id: s2_t
multiline: False
input_filter: "float"
Label:
text: "Sensor 3, a:"
TextInput:
id: s3_a
multiline: False
input_filter: "float"
Label:
text: "Sensor 3, b:"
TextInput:
id: s3_b
multiline: False
input_filter: "float"
Label:
text: "Sensor 3, c:"
TextInput:
id: s3_c
multiline: False
input_filter: "float"
Label:
text: "Sensor 3, Temp:"
TextInput:
id: s3_t
multiline: False
input_filter: "float"
Label:
text: "Sensor 4, a:"
TextInput:
id: s4_a
multiline: False
input_filter: "float"
Label:
text: "Sensor 4, b:"
TextInput:
id: s4_b
multiline: False
input_filter: "float"
Label:
text: "Sensor 4, c:"
TextInput:
id: s4_c
multiline: False
input_filter: "float"
Label:
text: "Sensor 4, Temp:"
TextInput:
id: s4_t
multiline: False
input_filter: "float"
Button:
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:
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()

111
gui/program/program.py Normal file
View File

@@ -0,0 +1,111 @@
from typing import List
from kivy.uix.screenmanager import Screen
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 lib.com import ComSuperClass
from kivy.clock import Clock
# The below list maps 0, 1, 2, 3 to a, b, c and t respectively
# This is used to set and read values of the UI
name_map = ["a", "b", "c", "t"]
class ProgramScreen(Screen):
def __init__(self, com: ComSuperClass, **kw):
self._com = com
self._instructions = Instructions(com)
self._decoder = Decoder()
super().__init__(**kw)
def load_config(self):
Clock.schedule_once(self._load)
# Load the current configuration from the micro-controller
def _load(self, dt: float):
# 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]] = []
# Load config for all four sensors
for _ in range(4):
# Receive 28 bytes of data
received = bytes()
try:
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),
)
return
# Create a list of strings to store the config for the sensor
# This list has the following elements: a, b, c, temperature
config_sensor_i: List[str] = []
# Create the list
for j in range(4):
config_sensor_i.append(
str(self._decoder.decode_float(received[7 * j : 7 * j + 6]))
)
# Add it to the config
config.append(config_sensor_i)
else:
TwoActionPopup().open(
"Failed to connect to micro-controller, retry?",
"Cancel",
empty_func,
"Retry",
lambda: self._load(0),
)
# Set the elements of the UI to the values of the config
def _set_ui(self, config: List[List[str]]):
for sensor_id in range(4):
for property in range(4):
self.ids[f"s{sensor_id + 1}_{name_map[property]}"].text = config[
sensor_id
][property]
# Read values from the UI. Returns the values as a list or None if the check was infringed
def _read_ui(self, enforce_none_empty: bool = True) -> List[float] | None:
data: List[float] = []
# Iterate over all sensor config input fields and collect the data
for sensor_id in range(4):
for property in range(4):
value = self.ids[f"s{sensor_id + 1}_{name_map[property]}"].text
# If requested (by setting enforce_none_empty to True, which is the default)
# test if the cells are not empty and if we find an empty cell return None
if enforce_none_empty and value == "":
return
data.append(float(value))
return data
# Transmit the changed data to the micro-controller to reconfigure it
def save(self):
data = self._read_ui()
if data == None:
SingleRowPopup().open("Some fields are missing values!")
else:
try:
self._instructions.change_config(data)
except:
SingleRowPopup().open("Could not save data!")
SingleRowPopup().open("Data saved successfully")
# 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/program/program.kv")