Schnellstart
Wie du Images schreibst, Disketten einliest und den Pi bequem bedienst.
Disketten-Transfer zwischen Commodore und modernen Rechnern — mit echter 1541-Hardware.
Wie du Images schreibst, Disketten einliest und den Pi bequem bedienst.
Direkt zu Hardware, Imager-Screenshots oder systemd-Service.
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.
Hardware, die ich für das Setup verwende.
So verwendest du das Setup im Alltag: Images schreiben, Disketten einlesen, sauber beenden.
~/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.
Stichwort „Diskettenlocher“: Diskettenlocher (Wikipedia).
Beispiel: Datei mit scp in ~/toDisk hochladen:
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 ./
Schritt für Schritt: von Hardware bis OpenCBM und Automatisierung.
Tool: Raspberry Pi Imager.
Vorbereitung: microSD-Karte am PC/Mac einstecken und den Imager starten.
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.
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.
ssh <YOUR-USERNAME>@<IP-ADRESSE>
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
Neustart
sudo reboot
Danach wird die SSH-Verbindung getrennt. Warte ca. 2 Minuten und verbinde dich anschließend erneut.
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.
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.
cbmctrl detect
mkdir -p /home/<YOUR-USERNAME>/toDisk /home/<YOUR-USERNAME>/fromDisk
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.
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
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