Jens Krüger

Raspberry Pi Floppy

Disketten-Transfer zwischen Commodore und modernen Rechnern — mit echter 1541-Hardware.

Überblick

Disketten rein. Images raus. Und umgekehrt.

Ziel dieses Projekts ist eine einfache und flexible Lösung, um Disketten für Commodore 64 und Commodore 128 vom und auf moderne Rechner zu übertragen. Der Kern ist ein Setup aus Raspberry Pi (4/5), ZoomFloppy und einer echten 1541 — robust, wiederholbar, alltagstauglich.

Warum?
Moderne PCs ohne Floppy-Controller — trotzdem echte Disketten nutzen.
Wie?
Pi + ZoomFloppy + 1541 als Brücke zwischen Diskette und Image.
Wofür?
Lesen, Schreiben, Verifizieren — saubere, nachvollziehbare Workflows.
Signalweg
Moderner Rechner
Raspberry Pi
ZoomFloppy
1541
Ziel: Disketten komfortabel lesen/schreiben, ohne Vintage-PC am Schreibtisch.

Teile-Liste

Hardware, die ich für das Setup verwende.

  • Raspberry Pi 4 oder 5
  • Raspberry Pi SenseHAT
  • ZoomFloppy
  • Commodore 1541
  • USB-Kabel, Floppy-Kabel (IEC)

Benutzung (Schnellstart)

So verwendest du das Setup im Alltag: Images schreiben, Disketten einlesen, sauber beenden.

Wichtig
Platzhalter
In allen Befehlen und Pfaden musst du <YOUR-USERNAME> durch deinen Linux-Benutzernamen auf dem Raspberry Pi ersetzen.
Prinzip

~/toDisk ist die Einwurfstelle für Images, die auf Diskette geschrieben werden sollen. Der Raspberry Pi beobachtet dieses Verzeichnis und schreibt alles auf Disketten, was er dort findet. .d64 funktioniert direkt — und du kannst auch .d64.gz ablegen: das wird automatisch entpackt.

IP-Adresse
Der Pi zeigt beim Start die IP auf dem SenseHAT an — wenn sie sich ändert, musst du nicht mehr im Router nachsehen.
D64 → Disk
Ein Image auf Diskette schreiben
  1. Floppy und Raspberry Pi einschalten.
  2. Diskette einlegen (sicherstellen, dass sie nicht schreibgeschützt ist).
  3. Das gewünschte .d64 oder .d64.gz in ~/toDisk kopieren.

Stichwort „Diskettenlocher“: Diskettenlocher (Wikipedia).

Aufräumen
Nach erfolgreichem Schreiben löscht der Automator die Datei auf dem Pi wieder automatisch.

Beispiel: Datei mit scp in ~/toDisk hochladen:

Disk → D64
Eine Diskette als Image einlesen
  1. Diskette zum Einlesen in die Floppy einlegen.
  2. Auf den SenseHAT-Joystick (mittig) drücken.
  3. Warten, bis ZoomFloppy und Floppy aufhören zu blinken.
  4. Das Image liegt dann in ~/fromDisk (Dateiname mit Zeitstempel).

Neueste Datei anzeigen:

ssh <YOUR-USERNAME>@<IP-ADRESSE> "ls -1t /home/<YOUR-USERNAME>/fromDisk | head"

Datei zurückholen (Dateiname ersetzen):

scp <YOUR-USERNAME>@<IP-ADRESSE>:/home/<YOUR-USERNAME>/fromDisk/disk_20251229-120000.d64 ./
Workflow
Eingelesene Images kannst du später über ~/toDisk wieder auf andere Disketten schreiben.
Joystick
SenseHAT Joystick-Funktionen
  • Middle: Diskette einlesen → ~/fromDisk
  • Up: IP-Adresse erneut anzeigen
  • Down: Raspberry Pi herunterfahren (Shutdown)

Installation

Schritt für Schritt: von Hardware bis OpenCBM und Automatisierung.

Schritt 1
Hardware zusammenbauen
  1. Den SenseHAT auf den GPIO-Header des Raspberry Pi aufstecken.
  2. Das ZoomFloppy-Board per USB an den Raspberry Pi anschließen.
  3. Die 1541 per IEC-Kabel mit dem ZoomFloppy verbinden.
  4. Die Floppy mit Strom versorgen (1541 per Netzkabel, 1541-II per Netzteil).
  5. Den Raspberry Pi ebenfalls mit einem Netzteil versorgen.
Schritt 2
Raspberry Pi OS auf die microSD schreiben (Raspberry Pi Imager)

Tool: Raspberry Pi Imager.

Platzhalter
Benutzername im Imager frei wählen — im Rest der Anleitung wird er als <YOUR-USERNAME> referenziert.

Vorbereitung: microSD-Karte am PC/Mac einstecken und den Imager starten.

Raspberry Pi Imager – Screenshot 01 Raspberry Pi Imager – Screenshot 02 Raspberry Pi Imager – Screenshot 03 Raspberry Pi Imager – Screenshot 04 Raspberry Pi Imager – Screenshot 05 Raspberry Pi Imager – Screenshot 06 Raspberry Pi Imager – Screenshot 07 Raspberry Pi Imager – Screenshot 08 Raspberry Pi Imager – Screenshot 09 Raspberry Pi Imager – Screenshot 10 Raspberry Pi Imager – Screenshot 11 Raspberry Pi Imager – Screenshot 12 Raspberry Pi Imager – Screenshot 13
Schritt 3
Erster Start

Wenn der Imager fertig ist, entnimmst du die microSD-Karte aus dem PC/Mac, steckst sie in den Raspberry Pi und schaltest ihn ein. Nach ein paar Minuten und ggf. mehreren Neustarts kannst du dich per SSH verbinden.

Schritt 4
IP-Adresse finden und per SSH verbinden

Die IP-Adresse findest du typischerweise in der Geräte-Liste deines Routers. Mit dieser IP kannst du dich anschließend per SSH verbinden. SSH ist heute auf macOS/Linux standardmäßig vorhanden und auch unter Windows (PowerShell/CMD) verfügbar.

Platzhalter
<IP-ADRESSE> durch die im Router angezeigte IP ersetzen.
ssh <YOUR-USERNAME>@<IP-ADRESSE>
Schritt 5
System aktualisieren
sudo apt update && sudo apt full-upgrade

Nachfragen bestätigst du mit Y (yes). Das kann eine Weile dauern.

EEPROM-Update (optional)

sudo rpi-eeprom-update
Hinweis
Falls eine Fehlermeldung erscheint, ist das meist kein Problem — dein Modell hat dann keinen (oder keinen separat aktualisierbaren) EEPROM.

Neustart

sudo reboot

Danach wird die SSH-Verbindung getrennt. Warte ca. 2 Minuten und verbinde dich anschließend erneut.

Schritt 6
Abhängigkeiten für OpenCBM installieren
sudo apt install libncurses-dev pkg-config libusb-1.0-0-dev git cc65

OpenCBM nutzen wir, um über das ZoomFloppy-Interface mit der 1541 zu sprechen.

Schritt 7
OpenCBM herunterladen, bauen und installieren
git clone https://github.com/OpenCBM/OpenCBM.git
cd OpenCBM
make -f LINUX/Makefile opencbm plugin-xum1541

Das dauert etwas.

sudo make -f LINUX/Makefile install install-plugin-xum1541

Dabei wird in der Regel nicht nochmal nach dem Passwort gefragt.

sudo ldconfig

Damit wird der Cache des dynamischen Linkers (ld.so) für Shared Libraries aktualisiert.

Schritt 8
ZoomFloppy / Floppy testen
cbmctrl detect
Troubleshooting
Wenn nichts gelistet wird: Kabel prüfen, Floppy wirklich eingeschaltet, IEC korrekt gesteckt.
Schritt 9
Arbeitsverzeichnisse anlegen
Platzhalter
In den Pfaden <YOUR-USERNAME> ersetzen.
mkdir -p /home/<YOUR-USERNAME>/toDisk /home/<YOUR-USERNAME>/fromDisk
Schritt 10
Automator-Skript erstellen

Als Nächstes erzeugen wir ein Python-Skript, das: ~/toDisk überwacht, .d64(.gz) auf Diskette schreibt, auf Knopfdruck Images nach ~/fromDisk einliest und die IP auf dem SenseHAT anzeigt.

Platzhalter
Ersetze alle Vorkommen von <YOUR-USERNAME> im folgenden Befehl (Pfad + Konstanten).
cat > /home/<YOUR-USERNAME>/zoomfloppy_automator.py <<'PY'
#!/usr/bin/env python3

import gzip
import logging
import os
import signal
import subprocess
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import List, Optional

from sense_hat import SenseHat


TO_DISK_DIR = Path("/home/<YOUR-USERNAME>/toDisk")
FROM_DISK_DIR = Path("/home/<YOUR-USERNAME>/fromDisk")
LOG_FILE = Path("/home/<YOUR-USERNAME>/log.txt")

DEVICE_ADDR = "8"
POLL_SECONDS = 10

SCROLL_SPEED = 0.06

stop_event = threading.Event()
opencbm_lock = threading.Lock()


def setup_logging() -> None:
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(levelname)s] %(message)s",
        handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler()],
    )
    logging.info("Start: zoomfloppy_automator")


def run_cmd(cmd: List[str], timeout_s: Optional[int] = None) -> subprocess.CompletedProcess:
    logging.info("CMD: %s", " ".join(cmd))
    return subprocess.run(
        cmd,
        check=False,
        capture_output=True,
        text=True,
        timeout=timeout_s,
    )


def get_ipv4_addrs() -> List[str]:
    cp = run_cmd(["ip", "-4", "-o", "addr", "show", "scope", "global"])
    out = cp.stdout.strip().splitlines()
    addrs = []
    for line in out:
        parts = line.split()
        if len(parts) >= 4 and parts[2] == "inet":
            iface = parts[1]
            ip_cidr = parts[3]
            ip = ip_cidr.split("/")[0]
            addrs.append(f"{iface}:{ip}")
    addrs.sort()
    return addrs


def show_ip_once(sense: SenseHat) -> None:
    addrs = get_ipv4_addrs()
    msg = "  ".join(addrs) if addrs else "no-ip"
    logging.info("IP-Anzeige: %s", msg)
    try:
        sense.show_message(msg, scroll_speed=SCROLL_SPEED)
    except Exception as e:
        logging.error("SenseHAT Anzeige Fehler: %s", e)


def safe_is_file_stable(p: Path, checks: int = 2, delay_s: float = 0.5) -> bool:
    try:
        last_size = p.stat().st_size
        last_mtime = p.stat().st_mtime
        for _ in range(checks):
            time.sleep(delay_s)
            st = p.stat()
            if st.st_size != last_size or st.st_mtime != last_mtime:
                return False
            last_size, last_mtime = st.st_size, st.st_mtime
        return True
    except FileNotFoundError:
        return False


def gunzip_to_same_dir(gz_path: Path) -> Optional[Path]:
    if not gz_path.name.lower().endswith(".gz"):
        return None

    out_path = gz_path.with_suffix("")
    tmp_path = out_path.with_suffix(out_path.suffix + ".part")

    try:
        logging.info("Entpacke: %s -> %s", gz_path, out_path)
        with gzip.open(gz_path, "rb") as fin, open(tmp_path, "wb") as fout:
            while True:
                chunk = fin.read(1024 * 1024)
                if not chunk:
                    break
                fout.write(chunk)
        os.replace(tmp_path, out_path)
        logging.info("Entpackt OK: %s", out_path)
        return out_path
    except Exception as e:
        logging.error("Entpacken fehlgeschlagen: %s (%s)", gz_path, e)
        try:
            if tmp_path.exists():
                tmp_path.unlink()
        except Exception:
            pass
        return None


def d64_write_to_drive(d64_path: Path) -> bool:
    with opencbm_lock:
        cp = run_cmd(["d64copy", "-v", str(d64_path), DEVICE_ADDR], timeout_s=600)
    if cp.returncode == 0:
        logging.info("d64copy OK: %s -> drive %s", d64_path, DEVICE_ADDR)
        return True
    logging.error("d64copy FEHLER (%s): %s %s", cp.returncode, cp.stdout, cp.stderr)
    return False


def d64_read_from_drive(out_path: Path) -> bool:
    out_path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = out_path.with_suffix(".part")

    with opencbm_lock:
        cp = run_cmd(["d64copy", "-v", DEVICE_ADDR, str(tmp_path)], timeout_s=600)

    if cp.returncode == 0:
        os.replace(tmp_path, out_path)
        logging.info("Disk -> d64 OK: drive %s -> %s", DEVICE_ADDR, out_path)
        return True

    logging.error("Disk -> d64 FEHLER (%s): %s %s", cp.returncode, cp.stdout, cp.stderr)
    try:
        if tmp_path.exists():
            tmp_path.unlink()
    except Exception:
        pass
    return False


def process_incoming_files_loop() -> None:
    TO_DISK_DIR.mkdir(parents=True, exist_ok=True)
    FROM_DISK_DIR.mkdir(parents=True, exist_ok=True)

    while not stop_event.is_set():
        try:
            candidates = sorted(TO_DISK_DIR.glob("*.gz")) + sorted(TO_DISK_DIR.glob("*.d64"))

            for p in candidates:
                if stop_event.is_set():
                    break
                if not p.is_file():
                    continue
                if not safe_is_file_stable(p):
                    continue

                d64_path: Optional[Path] = None

                if p.suffix.lower() == ".gz":
                    extracted = gunzip_to_same_dir(p)
                    if extracted is None:
                        continue

                    try:
                        p.unlink()
                        logging.info("Gelöscht: %s", p)
                    except Exception as e:
                        logging.warning("Konnte .gz nicht löschen: %s (%s)", p, e)

                    if extracted.suffix.lower() == ".d64":
                        d64_path = extracted
                    else:
                        logging.info("Entpackt, aber keine .d64: %s (überspringe)", extracted)
                        continue

                elif p.suffix.lower() == ".d64":
                    d64_path = p

                if d64_path is None:
                    continue

                if d64_write_to_drive(d64_path):
                    try:
                        d64_path.unlink()
                        logging.info("Gelöscht: %s", d64_path)
                    except Exception as e:
                        logging.warning("Konnte .d64 nicht löschen: %s (%s)", d64_path, e)

        except Exception as e:
            logging.error("Fehler im toDisk-Loop: %s", e)

        stop_event.wait(POLL_SECONDS)


def sensehat_joystick_loop(sense: SenseHat) -> None:
    while not stop_event.is_set():
        try:
            events = sense.stick.get_events()
            for ev in events:
                if ev.action != "pressed":
                    continue

                if ev.direction == "down":
                    logging.info("Joystick DOWN: Shutdown angefordert")
                    try:
                        sense.clear()
                        sense.show_message("SHUTDOWN", scroll_speed=0.08)
                    except Exception:
                        pass
                    run_cmd(["sudo", "shutdown", "-h", "now"])
                    stop_event.set()
                    break

                if ev.direction == "up":
                    logging.info("Joystick UP: IP anzeigen")
                    show_ip_once(sense)

                if ev.direction == "middle":
                    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
                    out = FROM_DISK_DIR / f"disk_{ts}.d64"
                    logging.info("Joystick MIDDLE: Disk sichern -> %s", out)
                    ok = d64_read_from_drive(out)
                    if not ok:
                        try:
                            sense.show_message("READ FAIL", scroll_speed=0.08)
                        except Exception:
                            pass
                    else:
                        try:
                            sense.show_message("SAVED", scroll_speed=0.08)
                        except Exception:
                            pass

        except Exception as e:
            logging.error("SenseHAT Joystick Fehler: %s", e)

        stop_event.wait(0.1)


def handle_signals(signum, frame) -> None:
    logging.info("Signal %s: Stop", signum)
    stop_event.set()


def main() -> None:
    setup_logging()

    signal.signal(signal.SIGINT, handle_signals)
    signal.signal(signal.SIGTERM, handle_signals)

    sense = SenseHat()
    try:
        sense.clear()
    except Exception:
        pass

    show_ip_once(sense)

    threads = [
        threading.Thread(target=process_incoming_files_loop, name="toDisk", daemon=True),
        threading.Thread(target=sensehat_joystick_loop, args=(sense,), name="joystick", daemon=True),
    ]

    for t in threads:
        t.start()

    while not stop_event.is_set():
        stop_event.wait(1.0)

    try:
        sense.clear()
    except Exception:
        pass

    logging.info("Stop: zoomfloppy_automator")


if __name__ == "__main__":
    main()
PY
Schritt 11
Skript ausführbar machen und Autostart (systemd)
Platzhalter
<YOUR-USERNAME> in den folgenden Befehlen und im Service-File ersetzen.
chmod +x /home/<YOUR-USERNAME>/zoomfloppy_automator.py
sudo tee /etc/systemd/system/zoomfloppy-automator.service >/dev/null <<'EOF'
[Unit]
Description=ZoomFloppy + SenseHAT Automator
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=<YOUR-USERNAME>
WorkingDirectory=/home/<YOUR-USERNAME>
ExecStart=/usr/bin/python3 /home/<YOUR-USERNAME>/zoomfloppy_automator.py
Restart=always
RestartSec=2
# Falls du eine Gruppen-udev-Regel nutzt:
# SupplementaryGroups=plugdev i2c

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now zoomfloppy-automator.service
Fertig
Damit ist die Installation abgeschlossen: Der Automator startet nun automatisch nach dem Booten.