Calliope mini Pinball Controller

In diesem Projekt sind Anleitungen und Code um einen Pinball Controller mit einem Calliope mini V3 zu bauen, welcher mit Pinball Videospielen wie zum Beispiel Pro Pinball: Timeshock! oder dem Visual Pinball Simulator benutzt werden kann. Dieser Pinball Controller besitzt 2 Flipper Knöpfe, einen Start Knopf, 1 Digitalen Flipper Kolben (zum Schießen der Kugel) und löst beim Schütteln über das Accelerometer "Stoß-Events" (TILT) aus. Der Pinball Controller versendet serielle Daten über die UART-Schnittstelle an den PC, welcher diese über ein Python Skript in Tastaturanschläge übersetzt, die dann die Komponenten des virtuellen Flippers betätigen :)

Demovideo

Hardware Aufbau

genutztes Material für diesen Aufbau

1 x Holz Schachtel etwa 20 x 8.5 x 75 cm
2 x beleuchteter Arcade Taster (groß) Ø 44mm
1 x Arcade Taster (klein)
1 x Taster (e.g. 12x12mm)
2 x lange M4 Schrauben
1 x M4 Innengewindestange
1 x Feder für M4 Schraube
1 x Calliope mini V3
1 x Servobard Calliope mini V3 (nicht unbedingt notwendig, macht die Verkabelung aber einfacher)
8 x Crimp-Kontakte
12 x Jumper-Kabel
1 x USB-C-Kabel
1 x PC
1 x Bildschirm (welcher horizontal gestellt werden kann, damit man einen großen Flipper hat)

Benutztes Werkzeug

1 x Akkuschrauber
1 x Heißkleber
1 x 5mm Holzbohrer
1 x 25mm Holzbohrer z.B. Forstner Bohrer

Bau Anleitung

  1. Bohre Löcher für die Arcade-Taster in die Box. Sei vorsichtig mit dem Loch oben für den roten Knopf, da der Schachteldeckel sehr dünn ist, lege auf der anderen Seite stärkeres Holz unter.
  2. Stecke das Servoboard auf den Calliope mini, dieses bietet genügend GND Pins für alle Schalter
  3. Bereite Kabel in passender Länge vor, löte Krimp Kontakte an Buchsen-Jumper-Drähte.
  4. Setze Taster ein und schließe Kabel testweise an
  5. Bohre Loch für das USB-Kabel
  6. Finde die Stelle, an der die Schraube für den Kolben eingesetzt werden kann. Bohre ein Loch für die Schraube und baue eine kleine Führung aus Holz (siehe Bild des Hardware Aufbau in der rechten oberen Ecke). Achte darauf, dass die Holzführung nicht den großen Arcade Taster behindert.
  7. Klebe Taster mit Heißkleber an passender Stelle fest
  8. Befestige Führung mit Heißkleber oder einer kleinen Holzschraube
  9. Füge Schraube, Feder und Innengewinde ein und schraube sie zusammen, um den Flipper Kolben zusammenzubauen
  10. Setze alle Taster ein
  11. Lege den Calliope mini an die passende Stelle, wie auf dem Bild "Hardware Aufbau" gezeigt
  12. Schließe die Kabel gemäß der Tabelle unten an

Anschluss der Taster

Taster Pin Calliope mini Pin Tastaturanschlag
Arcade Knopf 1 Pin 1 GND Links Shift
Arcade Knopf 1 Pin 2 C8 Links Shift
Arcade Knopf 1 LED Pin 1 M0+ -
Arcade Knopf 1 LED Pin 2 M0- -
Arcade Knopf 2 Pin 1 GND Rechts Shift
Arcade Knopf 2 Pin 2 C9 Rechts Shift
Arcade Knopf 2 LED Pin 1 M1+ -
Arcade Knopf 2 LED Pin 2 M1- -
Arcade Knopf(klein) Pin 1 GND Taste "s"
Arcade Knopf(klein) Pin 2 C13 Taste "s"
Taster Pin 1 GND Enter
Taster Pin 2 C12 Enter
- Beschleunigung Y+ Links Alt
- Beschleunigung Y- Rechts Alt
- Beschleunigung Z+ Leertaste

Code

Dieser Aufbau erfordert zum einen Code, der auf dem Calliope mini läuft, um serielle Daten entsprechend dem Tastendruck und den erkannten Gesten zu senden. Zum anderen läuft Code auf dem Host-Computer, welcher die seriellen Daten empfängt und entsprechende Tastaturanschläge auslöst, welche dann im Pinball Videospiel die verschiedenen Komponenten betätigen. Beide wurden in Python implementiert. Auf dem Calliope mini läuft Micropython, welches mit dem [Python Editor] (https://python.calliope.cc/) geflasht werden kann, auf dem Host-Computer läuft ein Python 3.8-Skript, für das die folgenden Abhängigkeiten installiert sein müssen. Der Code für den Hostcomputer wurde auf einem Windows und einem MacOS (Apple Silicon) System getestet.

Python Abhängigkeiten

  • pynput==1.8.1
  • pyserial==3.5

Installiere diese beiden mit pip für Python, benutze gegebenenfalls eine Virtuelle Umgebung. Mit dem Befehl
pip install -r requirements.txt oder
pip install pynput pyserial werden die Abhängigkeiten installiert.

Erklärung des Codes

Der erste Ansatz für den Code dieser Erweiterung basierte auf der Bluetooth-HID-Bibliothek für den Calliope mini, die ohne ein zusätzliches Skript auf dem Host-Computer funktioniert hätte. Leider war die Latenzzeit dieses Ansatzes zu groß für ein spaßbringendes Gameplay.
Innerhalb der Code Dateien gibt es Kommentare zur Erklärung, für ein erstes Verständnis erkläre ich hier grob den Ablauf.

Calliope mini Code

Der Quellcode des Python Programms für den Calliope mini befindet sich in der Datei mini-buttons2serial.py. In diesem Programm wird alle 10ms eine while-Schleife ausgeführt, die den aktuellen Status der Ereignisse per UART an den Host-Computer sendet. Alle Taster sind mit internen Pull-Up-Widerständen verbunden und werden bei Tastendruck auf GND gezogen, daher werden die Signale oft invertiert. Der Kolbenmechanismus hört auf Änderungen des Tastenzustands, wenn zwei Änderungen auftreten, wird das dazugehörige Ereignis ausgelöst. Für die Stoß-Erkennung (TILT) werden die Accelerometer Werte abgefragt und bei Überschreiten eines bestimmten Grenzwertes entsprechende Ereignisse ausgelöst.
Bei vielen Ereignisse wird eine Verzögerung eingesetzt, damit sie nicht mehrmals, sondern nur einmal über einen längeren Zeitraum ausgelöst werden.

Host Computer Code

Der Quellcode des Python-Programms des PCs befindet sich in der Datei serial2keyboard.py. Dieses Skript wartet auf neue Eingaben an der seriellen Schnittstelle, prüft die Integrität der seriellen Daten und löst Tastendrücke aus, wenn Ereignisse empfangen werden. Die entsprechende Taste wird gedrückt, wenn das Ereignis zum ersten Mal empfangen wird, und losgelassen, sobald es nicht mehr empfangen wird. Eine kleine Verzögerung am Ende des Codes sorgt dafür, dass die CPU nicht blockiert wird.
Wichtig: In Zeile 5 des Codes für den Hostcomputer muss der Serial Port des angesteckten Calliope mini eingetragen werden, dieser lässt sich über ein Serial Terminal Tool wie hterm oder über den Geräte-Manager herausfinden.

Code

Der gesamte Code befindet sich in einem Github Repository und ist außerdem unten aufgeführt:

Calliope mini Code:

# Imports go at the top
from calliopemini import *

# Prepare pins to read digital
pin8.set_pull(pin8.PULL_UP)
pin9.set_pull(pin8.PULL_UP)
pin12.set_pull(pin8.PULL_UP)
pin13.set_pull(pin8.PULL_UP)

# Light up arcade buttons
pin_M_MODE.write_digital(1)
pin_M1_DIR.write_digital(1) # Direction depends on wiring
pin_M1_SPEED.write_digital(1)
pin_M0_DIR.write_digital(0) # Direction depends on wiring
pin_M0_SPEED.write_digital(1)

# Variables for plunger / shooter
plunger_button, plunger_button_previous = 0, 0
plunger_ready, shoot = 0, 0

# Variables for bump detection
acc_x, acc_y, acc_z = 0, 0, 0
bump_left, bump_right, bump_up = 0, 0, 0
bump_detected = 0
# Variable for player button
player_button = 0

# Code in a 'while True:' loop repeats forever
while True:
    plunger_button = int(pin12.read_digital())

    if plunger_button != plunger_button_previous: # If there was a change in the plunger button
        if not plunger_ready:
            plunger_ready = 1 # Enable plunger to shoot when there is the next change in the plunger button

        elif plunger_ready:
            plunger_ready = 0
            shoot = 50 # This variable is decremented by time and keeps the button pressed for 500ms

    if not pin13.read_digital():
        player_button = 20 # This variable is decremented by time and keeps the button pressed for 200ms


    if shoot:
        shoot -= 1

    if player_button:
        player_button -= 1


    if bump_detected:
        bump_detected -= 1
        if bump_detected == 1: # Resets all bump values when bump 
            bump_left = 0
            bump_right = 0
            bump_up = 0

    acc_x, acc_y, acc_z = accelerometer.get_values()

    if acc_x > 1200 and not bump_detected and not shoot: 
        bump_up = 1
        bump_detected = 20 # Space bar creates tilt faster

    if acc_y > 700 and not bump_detected and not shoot: 
        bump_left = 1
        bump_detected = 50 

    if acc_y < -700 and not bump_detected and not shoot: 
        bump_right = 1
        bump_detected = 50     

    print(
    int(not pin8.read_digital()), # Left arcade button (Left shift)
    int(not pin9.read_digital()), # Right arcade button (Right shift)
    int(bool(player_button)), # Player Button ('s')
    int(bool(shoot)),                    # Activate Plunger (Enter)
    int(bump_left),               # Bump Table to left (Left Alt)
    int(bump_right),               # Bump Table to right (Right Alt)
    int(bump_up)                   # Bump Table up (Spacebar)
    )

    plunger_button_previous = plunger_button
    sleep(10)

Host Computer Code

import serial
from pynput.keyboard import Controller, Key
import time
# Set up the serial port (adjust your COM port accordingly)
serial_port = 'COM5'  # Adjust for your operating system (e.g., COMx on Windows)
baud_rate = 115200  # Baud rate (adjust as needed)
# Initialize serial connection
ser = serial.Serial(serial_port, baud_rate, timeout=1)
ser.flushInput()
# Initialize the keyboard controller to simulate key presses
keyboard = Controller()
key_state = {'a': False, 'b': False, 'c': False, 'd': False, 'e': False, 'f': False, 'g': False}
# For momentary press tracking
signal2_previous = 0
signal3_previous = 0
# Main loop
while True:
    if ser.in_waiting > 0:  # Check if data is available on the serial port
        data = ser.readline().decode('utf-8').strip()  # Read the input from serial
        # Expecting a string like "0 0 0 0 0 0 0"
        signals_string = data.split(' ')

        # Only proceed if we get exactly 7 values
        if len(signals_string) == 7:
            try:
                # Convert the list of strings to integers (with added check to avoid empty strings)
                signals = [int(signal) if signal != '' else 0 for signal in signals_string]

                # Regular press and hold behavior for signals 0, 1, 4, 5
                if signals[0] and not key_state['a']:  # If 'a' should be pressed
                    keyboard.press(Key.shift_l)  # Left Shift
                    key_state['a'] = True
                elif not signals[0] and key_state['a']:  # If 'a' should be released
                    keyboard.release(Key.shift_l)
                    key_state['a'] = False

                if signals[1] and not key_state['b']:  # If 'b' should be pressed
                    keyboard.press(Key.shift_r)
                    key_state['b'] = True
                elif not signals[1] and key_state['b']:  # If 'b' should be released
                    keyboard.release(Key.shift_r)
                    key_state['b'] = False

                if signals[2] and not key_state['c']:  # If 'b' should be pressed
                    keyboard.press('s')
                    key_state['c'] = True
                elif not signals[2] and key_state['c']:  # If 'b' should be released
                    keyboard.release('s')
                    key_state['c'] = False


                if signals[3] and not key_state['d']:  # If 'b' should be pressed
                    keyboard.press(Key.enter)
                    key_state['d'] = True
                elif not signals[3] and key_state['d']:  # If 'b' should be released
                    keyboard.release(Key.enter)
                    key_state['d'] = False


                if signals[4] and not key_state['e']:  # If 'e' should be pressed
                    keyboard.press(Key.alt_r)
                    key_state['e'] = True
                elif not signals[4] and key_state['e']:  # If 'e' should be released
                    keyboard.release(Key.alt_r)
                    key_state['e'] = False

                if signals[5] and not key_state['f']:  # If 'f' should be pressed
                    keyboard.press(Key.alt_l)
                    key_state['f'] = True
                elif not signals[5] and key_state['f']:  # If 'f' should be released
                    keyboard.release(Key.alt_l)
                    key_state['f'] = False

                # Added new signal for space bar
                if signals[6] and not key_state['g']:  # If 'g' should be pressed
                    keyboard.press(Key.space)
                    key_state['g'] = True
                elif not signals[6] and key_state['g']:  # If 'g' should be released
                    keyboard.release(Key.space)
                    key_state['g'] = False

            except ValueError:
                print(f"Error converting data: {data}")
        else:
            print(f"Received unexpected number of values: {data}")

    time.sleep(0.0001)  # Small delay to avoid high CPU usage