malte70.blog()

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):

MAPID in Aktion (der Schlüsselschalter ist ohne Funktion und stammt aus einem früheren Projekt mit diesem Gehäuse)

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:

Da das Skript bei mir unter macOS ausgeführt wird, muss statt 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"