Projektvorstellung: MAPID (Malte's Arduino powered info display)
Nachdem ich vor einigen Wochen beim Aufräumen auf einen 16×2-Zeichen-Display gestoßen bin, den ich für meine Arduinos gekauft hatte, habe ich auch meine seit Jahren nicht angefassten Arduino-Sketches durchstöbert. Dabei bin ich auf ein CLI-„Betriebssystem“ zur Ansteuerung einer LED und eines LCD gestoßen.
Da kam mir die Idee, diesen zur Anzeige von Benachrichtigungen auf dem LCD neben meinem Monitor zu nutzen.
Das Projekt besteht aus einem Arduino-Sketch, MAPID/CP, und einem Python-Skript (fifo_bridge.py), das auf meinem Desktop-Rechner läuft. Im Git-Repo findet sich zusätzlich ein Web-Interface zu Debugging-Zwecken, dass mithilfe des Python-Skripts den Display kontrolliert, auf das ich hier jedoch nicht eingehe.
Den dazugehörigen Code habe ich in einem Git-Repo auf dem Forjo-Server meines lokalen Hackspace veröffentlicht.
So sieht das ganze dann in Aktion aus (Ausgabe des MAPID/CP-Kommandos GETTY
):
Arduino-Sketch
Ein Hauptbestandteil von MAPID ist der Arduino-Sketch MAPID/CP („CP“ steht für Control Program). Er ist größtenteils aus alten Sketches von mir zusammenkopiert, da ich schon länger nichts mit meinen Arduinos gemacht hatte, vor allem einem Sketch zur Kontrolle einer LED via Serieller Kommandozeile. Im folgenden gehe ich nur auf die wichtigsten Funktionen des vollständigen Sketches im Git-Repo ein.
Verwendete Hardware
Als Mikrocontroller verwende ich meinen ältesten Arduino, den Arduino Duemilanove mit ATmega 328p-Chip. Der 16×2-Zeichen-LCD ist auch ein älteres Modell, mit ausschließlich per UART steuerbarem SerLCD-Chip.
Der LCD ist an Port 3 des Arduino für die Verwendung von SoftwareSerial angeschlossen, damit die Default-UART-Ports 0 und 1 für die Verwendung über USB frei bleiben. Die LED ist an Port 13 angeschlossen; während der Entwicklung habe ich dort die Onboard-LED genutzt.
config.h
Die Konfiguration eines Sketches lagere ich gerne in eine config.h
-Datei aus, um sie einfach wiederzufinden. Hier wird neben den PINs für den Display und die LED auch die Terminal-Übertragungsrate und der Prompt festgelegt.
#ifndef _CONFIG_H_
#define _CONFIG_H_
#define PIN_LED LED_BUILTIN
#define PIN_LCD 2
#define TTY_SPEED 9600
#define TTY_PROMPT "?"
#endif
MAPID-CP.ino
Die Hauptarbeit leistet die Bibliothek SerialCommand Advanced. In der setup()
-Funktion werden die Kommandos an Handler-Funktionen gebunden:
#include <SoftwareSerial.h>
#include <serLCD.h>
#include <SerialCommand.h>
#include "config.h"
SerialCommand SCmd;
serLCD lcd(PIN_LCD);
int line;
void setup() {
pinMode(PIN_LED, OUTPUT);
digitalWrite(PIN_LED, LOW);
Serial.begin(TTY_SPEED);
while (!Serial);
// LED
SCmd.addCommand("ON", ledOn);
SCmd.addCommand("OFF", ledOff);
// LCD
SCmd.addCommand("LCDON", lcdOn);
SCmd.addCommand("LCDOFF", lcdOff);
SCmd.addCommand("ECHO", lcdEcho);
SCmd.addCommand("CLS", lcdClear);
SCmd.addCommand("LINE", lcdLine);
SCmd.setDefaultHandler(commandNotFound);
// Show prompt
Serial.println();
Serial.print(TTY_PROMPT);
}
void loop() {
SCmd.readSerial();
}
/**
* Default handler for unknown commands
*/
void commandNotFound(const char *cmd) {
Serial.println("");
Serial.println("[ERROR] COMMAND NOT FOUND!");
// Show prompt again
Serial.print(TTY_PROMPT);
}
Die Kommandos, hier LCDON
, schreiben auf die Serielle Konsole eine Statusmeldung (auch wenn diese nur in interaktiven Sitzungen irgendeinen Zweck hat). Da SerialCommand Advanced keine Prompts unterstützt, muss jede Kommando-Funktion diesen am Ende anzeigen:
/**
* LCDON → Turn display and backlight on
*/
void lcdOn() {
Serial.println("");
// Turn display on
Serial.println("Turning LCD screen to *ON*");
lcd.display();
lcd.setBrightness(30);
// Show prompt again
Serial.print(TTY_PROMPT);
}
Das ECHO
-Kommando hohlt sich mit SCmd.next()
die nächsten Tokens bis zum Kommandoende, damit Leerzeichen im Text unterstützt werden. Leider sind mehrere Leerzeichen in Folge so jedoch nicht möglich.
/**
* ECHO <text> → Write text on LCD
*/
void lcdEcho() {
Serial.println("");
char *text;
Serial.print(" [INFO] Writing text to LCD: ");
for (int i=0; i<16; i++) {
// Loop repeats once per token
text = SCmd.next();
if (text == NULL)
break;
// Since tokens don't include SPACE, print it to debug OUTPUT
if (i > 0)
Serial.print(" ");
Serial.print(text);
if (i > 0)
lcd.print(" ");
lcd.print(text);
}
// Show prompt again
Serial.println();
Serial.print(TTY_PROMPT);
}
Python-Client mit pySerial
Die Kommandozeile von MAPID/CP ist (auch) für interaktive Nutzung gedacht, aber hauptsächlich soll sie von einem Python-Skript auf meinem Mac mini aus gesteuert werden.
Konfiguration via dotenv
Für die Konfiguration nutze ich eine .env
-Datei, da diese sowohl in Shell-Skripts, als auch mit Python (obgleich nur über eine externe Bibiothek) einfach zu verwenden ist.
.env
a.k.a. Dotenv-Dateien sind immer Bourne-Shell kompatible Skripte, die eine Reihe von Variablen festlegen. Die PyPI-Seite von python-dotenv erklärt die Syntax.# Serial device
SERIAL_DEV="/dev/cu.usbserial-A700e0gN" # macOS
#SERIAL_DEV="/dev/ttyUSB0" # Linux
# Serial baud rate
SERIAL_SPEED=9600
# FIFO filename
FIFO="/tmp/mapid"
Python-Bibliothek mapid.py
Für die Steuerung der Seriellen Konsole habe ich ein Python-Modul implementiert, dass Befehle via pySerial weiterreicht. Die Konfiguration wird mittels python-dotenv geladen:
"""MAPID utility library.
This module contains an utility class to control MAPID/CP using pyserial.
"""
import os
import time
import dotenv
import serial
DOTENV_LOADED = False
"""bool: Is .env loaded?
Prevents loading .env multiple times for speed.
"""
def load_dotenv():
"""Load .env file
"""
basedir = os.path.abspath(os.path.dirname(__file__))
dotenv.load_dotenv(os.path.join(basedir, ".env"))
global DOTENV_LOADED
DOTENV_LOADED = True
def get_serial_dev():
"""Get SERIAL_DEV setting from dotenv.
Returns:
str: The device file.
"""
global DOTENV_LOADED
if not DOTENV_LOADED:
load_dotenv()
return os.environ.get("SERIAL_DEV")
def get_serial_speed():
"""Get SERIAL_SPEED setting from dotenv.
Returns:
int: Serial speed in baud.
"""
global DOTENV_LOADED
if not DOTENV_LOADED:
load_dotenv()
return os.environ.get("SERIAL_SPEED")
class MAPIDCP:
arduino = None
def __init__(self):
self.arduino = serial.Serial(port=get_serial_dev(), baudrate=get_serial_speed(), timeout=.1)
def _cmd(self, cmd: str):
self.arduino.write(bytes(cmd + "\r\n", "utf8"))
time.sleep(0.1)
def cls(self):
self._cmd("CLS")
def led(self, state: bool):
if state:
self._cmd("ON")
else:
self._cmd("OFF")
def line(self, line_no: int):
self._cmd("LINE " + str(line_no))
def echo(self, text: str):
self._cmd("ECHO " + text)
FIFO-Bridge fifo_bridge.py
Da nur mein Benutzer Zugriff auf MAPID/CP braucht, werden Befehle über eine FIFO weitergereicht. In einer Endlosschleife werden Zeilen aus der FIFO gelesen und via mapid.MAPID._cmd()
an die serielle Konsole weiter gereicht. Der Name der FIFO-Datei wird beim Start auf dem Display angezeigt.
#!/usr/bin/env python3
import os
import sys
import time
import mapid
from serial.serialutil import SerialException
mapid.load_dotenv()
FIFO = os.environ.get("FIFO")
def main():
global FIFO
if len(sys.argv) == 2:
FIFO = sys.argv[1]
m = mapid.MAPIDCP()
os.mkfifo(FIFO)
time.sleep(0.2)
print("Created FIFO " + FIFO, file=sys.stderr)
f = open(FIFO, "r")
print("Opened FIFO for reading", file=sys.stderr)
m.cls()
m.line(1)
m.echo("fifo_bridge.py")
m.line(2)
m.echo(FIFO)
keep_going = True
while keep_going:
try:
line = f.readline().strip("\n")
m._cmd(line)
except KeyboardInterrupt:
keep_going = False
print()
except SerialException:
keep_going = False
print("Caught a SerialException. Exiting now...", file=sys.stderr)
os.remove(FIFO)
print("Removed FIFO.", file=sys.stderr)
if __name__ == "__main__":
main()
Benachrichtigungs-Skript notification.sh
Der Hauptzweck von MAPID ist es, Benachrichtigungen darzustellen. Dafür habe ich das Skript notofication.sh
geschrieben, das die von fifo_bridge.py
erstellte FIFO nutzt.
Es werden die Texte für die beiden Display-Zeilen gelesen, und auf dem Display angezeigt. Für 0,5 Sekunden blinkt die LED auf, und nach weiteren 10 Sekunden wird der Display wieder ausgeschaltet:
sleep
die GNU-Variante gsleep
verwendet werden, um Zeitangaben von unter einer vollen Sekunde zu erlauben.#!/usr/bin/env bash
source "$(dirname $0)/.env"
[[ -t 0 ]] && printf "Line 1> "; read LINE1
[[ -t 0 ]] && printf "Line 2> "; read LINE2
LED_BLINK=".5s"
DISPLAY_DURATION="10s"
if [[ ! -e $FIFO ]]; then
echo "Error: $FIFO does not exist." >&2
exit 2
fi
echo >$FIFO "CLS"
if [[ -n $LINE1 ]]; then
echo >$FIFO "LINE 1"
echo >$FIFO "ECHO $LINE1"
fi
if [[ -n $LINE2 ]]; then
echo >$FIFO "LINE 2"
echo >$FIFO "ECHO $LINE2"
fi
echo >$FIFO "LCDON"
echo >$FIFO "ON"
gsleep $LED_BLINK
echo >$FIFO "OFF"
gsleep $DISPLAY_DURATION
echo >$FIFO "LCDOFF"