Code und Projekt einsehbar auf GitHub!
Aufgaben des Backends
Das Backend ist das zentrale Steuerglied des Projektes. Es hat viele Aufgaben, die teilweise gleichzeitig erledigt werden müssen, die im Folgenden aufgelistet sind:
-
Kommunikation mit dem Arduino Der Arduino stellt, wie in den vorherigen Beiträgen bereits erklärt, die Schnittstelle zur Hardware (Spielstandsanzeige und Input-Device’s) dar. Das heißt, das Backend muss sowohl Befehle an den Arduino senden als auch empfangen können.
-
Kommunikation mit der Datenbank Alle Spielerprofile, Spiele und Sessions müssen gespeichert werden (Sie sollen ja nicht nur in dem RAM leben, sondern auch nach einem Reboot noch vorhanden sein)
-
Kommunikation mit dem User Das Backend muss eingehende GET und POST Befehle beantworten können. Mit einer HTML GET Anfrage fragt der User z. B. nach der Homepage und das Backend antwortet mit dem richtigen HTML File. Mit einer POST Anfrage kann beispielsweise ein Spieler angelegt oder ein Tor registriert werden.
Dabei fällt auf, dass das Backend mehrere Aufgaben gleichzeitig erledigen muss, denn zu jedem Zeitpunkt muss nach eingehenden Arduino und HTML Befehl „gehorcht“ werden. Eine gewisse Parallelität ist also nötig. Eine elegante Lösung bilden hier asynchrone Funktionen.
Kommunikation mit dem Arduino
Wie eben erklärt, muss das Backend zur Kommunikation “horchen & senden” gleichzeitig können. Im Folgenden ist die Implementierung der Klasse ArduinoAsyncSerial
zu sehen, die die Schnittstelle zum Arduino definiert.
(File auf GitHub communication.py )
class ArduinoAsyncSerial:
def __init__(self):
self.url = '/dev/ttyACM0'
self.baudrate = 115200
self.reader = None
self.writer = None
self.available = False
async def startup(self):
try:
await self._open_connection()
except:
self.available = False
else:
asyncio.create_task(self._listen())
self.available = True
logger.debug("Serial connection opened.")
async def _open_connection(self):
self.reader, self.writer = await open_serial_connection(
url=self.url, baudrate=self.baudrate)
async def _listen(self):
try:
while True:
rawline = await self.reader.readline()
try:
line = str(rawline, 'utf-8')
dic = json.loads(line)
except:
logger.warning("Received unreadable JSON.")
else:
asyncio.create_task(self._decode(dic))
logger.debug(f"Received package {dic}.")
except Exception as e: # TODO
self.available = False
async def _write(self, mode, msg):
package = {
"mode": mode,
"msg": msg
}
raw = (str(json.dumps(package)) + "\n").encode()
try:
self.writer.write(raw)
except Exception as e:
raise e # TODO
else:
logger.debug(f"Sent package {package}.")
def set_button_callback(self, callable):
self._button_callback = callable
async def _decode(self, dic):
mode = dic["mode"]
msg = dic["msg"]
if mode == "pressed":
logger.debug(f"Decoded as button press at position {msg}.")
await self._button_callback(msg)
elif mode == "echo":
logger.debug(f"Decoded as echo with message {msg}.")
elif mode == "error":
raise Exception(f"Error from Arduino: {msg}")
async def set_leds(self, position, player_history):
leds = [[0, 0, 0] for _ in range(ARRAYLEN)]
for i in range(len(player_history)):
if player_history[i] == "g":
leds[i] = GREEN
elif player_history[i] == "o":
leds[i] = RED
else:
raise ValueError
msg = {
"position": position,
"leds": leds
}
await self._write("setled", msg)
In der main
wird eine Instanz dieser Klasse kreiert. Damit das Backend dauerhaft nach eingehenden Befehlen hört, muss eine serielle Verbindung aufgebaut werden. Das passiert, indem die asynchrone Methode startup
aufgerufen (bzw “awaited”) wird. Klappt das erfolgreich, wird ein asynchroner Task erstellt. In diesem läuft die asynchrone Methode _listen
dauerhaft, ohne andere Programmteile zu blockieren.
Sendet der Arduino nun ein Befehl an das Backend, wird dieser (wenn es keinen Übertragungsfehler gab und als JSON lesbar ist) als asynchroner Task von der Methode _decode
decodiert. Es wird also bestimmt, welcher Knopf gedrückt wurde. Das Ergebnis wird an die Funktion _button_callback
weitergegeben und an einer anderen Stelle im Programm verarbeitet (d.h. auf validiert und wirklich in die Datenbank geschrieben).
Aktuell sind keine anderen ausgehenden Befehle als das “setzen” der LED’s nötig. Soll dies geschehen, wird die asynchrone Methode set_leds
aufgerufen.
Kommunikation mit der Datenbank (Core & Database)
Um zu verstehen, wie die Kommunikation mit der Datenbank funktioniert, soll hier ein konkretes Beispiel dienen. Wollen wir ein Spiel spielen, muss zunächst eine Instanz der Klasse Game
erzeugt werden. Danach müssen Spieler an den Slots registriert werden. Dabei müssen schon einige Dinge beachtet werden:
- Existiert bereits ein aktives Spiel?
- Existieren die registrierten Spieler?
- uvm…
Angenommen das Spiel wurde erstellt und der Spieler Schwarz-Defense schießt ein Tor. Dies wird nun über einen Knopf an das Backend mitgeteilt. Das Backend muss nun wieder einiges überprüfen:
- Ist überhaupt ein Spiel am Laufen?
- Beendet dieses Tor das Spiel?
- uvm…
Die naheliegendste Lösung ist es, hierfür eine Klasse Game
zu implementieren. Zu beachten ist dabei, dass die Klasse nicht nur den Mechanismus eines Spieles abbildet. Sie ist eine Vererbung der Klasse BaseModel
. Mit dieser ist es möglich, über das Modul peewee
Datenbank Aktionen auszuführen. So kann z.b. via der Methode Game.save
das Spiel in einer Sqlite3 Datenbank gespeichert werden.
Neben der Klasse Game
sind einige weitere implementiert, die alles rund ums Kickern abbilden und gleichzeitig speicherbar machen (z. B. Event
und Player
). In diesem Teil ist also das Herz des Projektes programmiert.
Da die einzelnen Klassen viele Zeilen umfassen und diesen Blogbeitrag völlig aufblähen würden, zeige ich hier keinen Code. Stattdessen kann das File core.py auf GitHub eingesehen werden.
Kommunikation mit dem User (Webserver & Frontend)
Webserver
Damit ein User die Website staticker.local aufrufen kann, muss auf dem Raspberry Pi ein Webserver laufen. Hierzu wird das asynchrone Framework FastAPI genutzt. Der Webserver wird dann über das Framework uvicorn gestartet. Das lässt sich auch in der *_main_.py* auf GitHub gut sehen:
from .log import logger
from .app import app
import uvicorn
if __name__ == "__main__":
logger.info("Starting staticker")
uvicorn.run(app, host='0.0.0.0')
Der Webserver wird auf der IP 0.0.0.0
gestartet, wodurch es im Netzwerk sichtbar wird. Bei der finalen Version muss evtl. noch der richtige Port (80) angegeben werden.
Ruft der User nun staticker.local und damit die Homepage auf, wird dem User eine HTML-Response zurück gesendet. Dies geschieht in der Funktion app_root
aus der Datei app.py .
Will der User nun z. B. in das Player-Menü, so klickt er auf Player im Menüband. In der HTML ist der Link staticker.local/player/
(jedoch implizit) hinterlegt. Das Framework FastAPI löst diesen Link nun auf und führt die Funktion player_overview
aus der Datei routes/player.py aus.
Für einen besseren Überblick: Alle Routes (z. B. staticker.local/game/123
oder staticker.local/player/new
) sind im Ordner /routes
zu finden. Nur die Homepage, also die Root-Route ist in der Datei app.py
.
Frontend
Ein besonders aufwendiger Teil des Projekt’s ist das Frontend. Das liegt aber (glaub ich zumindest) daran, dass ich noch nie Frontend entwickelt habe. Wie auch immer…
Da sehr viele Bestandteile auf jeder Sub-Seite der Website gleich sind (z. B. Menüband oder Footer), nutze ich das Template-Framework jinja2. Hiermit lassen sich HTML-Files durch einzelne “Bausteine” zusammensetzen. Alle HTML-Files sind im Ordner /templates
zu finden. Als Beispiel kann hier die Erzeugung einer einfachen HTML-Liste mit jinja2 dienen, bei dem über die Variable players
iteriert wird (aus dem File templates/player-overview.html ):
<ul>
{% for player in players %}
<li>
<a href="id/{{ player.id }}">{{ player.name }}</a>
</li>
{% endfor %}
</ul>
Für ein professionelleres Aussehen und eine funktionellere Programmierung wird das CSS-Framework Bootstrap4 genutzt. Hiermit lassen sich z. B. recht einfach Forms zur Eingabe von Daten erstellen (aus dem File templates/new_player.html ):
<form action="new/submit" method="post">
<div class="form-group">
<label for="name">Enter name</label>
<input type="text" class="form-control" id="name" name="name" placeholder="your name">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>