feat(drives): V2-4 (Teil 1) — Windows-Laufwerkstreiber, ohne Hardware geprueft
Ampel / ampel (push) Successful in 45s
Ampel / ampel (push) Successful in 45s
WAS: rippy/drives/windows.py mit denselben zwei Vertraegen wie die Linux-Fassung (auswerfen_versuchen gibt False zurueck, eject wirft). Dazu win_ioctl.py: die Win32-Steuercodes, HERGELEITET statt abgeschrieben. WARUM ctypes und nicht pywin32: 20 MB zusaetzliche Abhaengigkeit, die in jedes PyInstaller-Paket muss und bei jedem Python-Wechsel neu passen will. Gebraucht werden fuenf Funktionen aus kernel32 — die spricht ctypes direkt an. DIE STEUERCODES WERDEN AUSGERECHNET, NICHT ABGETIPPT: win_ioctl.py enthaelt das CTL_CODE-Makro aus winioctl.h als Funktion; jede Konstante wird daraus gebildet. test_win_ioctl.py rechnet das Ergebnis gegen die in der Microsoft-Dokumentation stehenden Zahlen. Grund: Ein falscher IOCTL-Code meldet kein "unbekannter Befehl", sondern ERROR_INVALID_FUNCTION — und das liest sich wie "dieses Laufwerk kann das nicht". Man sucht dann am Geraet statt an einer Zahl. AGENTS Regel D, ernst genommen. DIE ZWEI PORT-REGELN GELTEN AUCH HIER: - Auswerfen heisst entriegeln, auswerfen, NACHSEHEN. Unter Linux wurde am 26.07.2026 gemessen, dass ein verriegeltes Laufwerk den Auswurf mit ERFOLG quittiert und nichts tut. IOCTL_STORAGE_EJECT_MEDIA meldet ebenfalls nur die Annahme des Befehls — also wird auch hier nachgesehen (CHECK_VERIFY2 muss ERROR_NOT_READY liefern). - Ein unzugaengliches Laufwerk ist UNBEKANNT, nicht leer. device_info kennt deshalb drei Zustaende, nicht zwei. EIN FEHLER, DEN DIE TESTS SOFORT GEFANGEN HABEN: Win32Fehler setzte self.winerror VOR super().__init__(). Nachgemessen: Ein zweiargumentiges OSError.__init__(code, text) setzt winerror wieder auf None. Damit erkannte _medium_da ERROR_NOT_READY nicht mehr, hielt jedes leere Laufwerk fuer einen echten Fehler und meldete "unbekannt" statt "leer". Vier Tests rot, Ursache in einer Minute gemessen statt geraten. ⚠️ WAS HIER NICHT BEWIESEN IST: dass echte Hardware sich so verhaelt. Das Laufwerk ist derzeit nirgends angeschlossen. Der Ablauf, die Fehlerunterscheidung und die Typ-Zuordnung sind gegen ein nachgebautes Laufwerk geprueft (51 Tests, laufen auf jeder Plattform) — die Hardware- Schicht gilt bis zu einer Messung als GEBAUT, nicht als BEWIESEN. GEMESSEN: ruff sauber, 400 Tests gruen + 4 uebersprungen (vorher 378). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4337253797
commit
b723fee3a2
@@ -0,0 +1,84 @@
|
|||||||
|
"""Die Win32-Steuercodes gegen die dokumentierten Zahlen rechnen.
|
||||||
|
|
||||||
|
## Warum dieser Test wichtig ist
|
||||||
|
|
||||||
|
Ein falscher IOCTL-Code liefert kein „unbekannter Befehl". Windows antwortet
|
||||||
|
mit `ERROR_INVALID_FUNCTION` — und das liest sich wie „dieses Laufwerk kann
|
||||||
|
das nicht". Man sucht dann tagelang am Gerät statt an einer Zahl.
|
||||||
|
|
||||||
|
Die Werte rechts sind die in `winioctl.h` / `ntddcdrm.h` und auf Microsoft
|
||||||
|
Learn dokumentierten Konstanten. Links steht, was `ctl_code()` aus den
|
||||||
|
Bestandteilen bildet. Stimmen beide überein, ist die Herleitung richtig.
|
||||||
|
|
||||||
|
Läuft auf JEDER Plattform: Hier wird nur gerechnet, nichts aufgerufen. Der
|
||||||
|
Zahlendreher fällt damit schon auf dem Entwicklungsrechner auf und nicht erst
|
||||||
|
an einem echten Laufwerk.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rippy.drives import win_ioctl as w
|
||||||
|
|
||||||
|
|
||||||
|
def test_ctl_code_bildet_das_makro_aus_winioctl_h_ab():
|
||||||
|
"""CTL_CODE(DeviceType, Function, Method, Access) =
|
||||||
|
(DeviceType << 16) | (Access << 14) | (Function << 2) | Method"""
|
||||||
|
assert w.ctl_code(0, 0, 0, 0) == 0
|
||||||
|
assert w.ctl_code(1, 0, 0, 0) == 1 << 16
|
||||||
|
assert w.ctl_code(0, 1, 0, 0) == 1 << 2
|
||||||
|
assert w.ctl_code(0, 0, 3, 0) == 3
|
||||||
|
assert w.ctl_code(0, 0, 0, 1) == 1 << 14
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name,erwartet", [
|
||||||
|
# Die Zahlen aus der Microsoft-Dokumentation bzw. den SDK-Headern.
|
||||||
|
("IOCTL_STORAGE_CHECK_VERIFY2", 0x002D0800),
|
||||||
|
("IOCTL_STORAGE_MEDIA_REMOVAL", 0x002D4804),
|
||||||
|
("IOCTL_STORAGE_EJECT_MEDIA", 0x002D4808),
|
||||||
|
("IOCTL_STORAGE_LOAD_MEDIA", 0x002D480C),
|
||||||
|
("IOCTL_CDROM_DISK_TYPE", 0x00020040),
|
||||||
|
("IOCTL_DISK_GET_LENGTH_INFO", 0x0007405C),
|
||||||
|
])
|
||||||
|
def test_steuercode_stimmt_mit_der_dokumentation_ueberein(name, erwartet):
|
||||||
|
assert getattr(w, name) == erwartet, (
|
||||||
|
f"{name} ist {getattr(w, name):#010x}, dokumentiert ist {erwartet:#010x}. "
|
||||||
|
"Ein falscher Steuercode meldet ERROR_INVALID_FUNCTION — das sieht aus "
|
||||||
|
"wie 'das Laufwerk kann das nicht' und schickt die Fehlersuche in die "
|
||||||
|
"falsche Richtung."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Gerätepfad ──────────────────────────────────────────────────────────
|
||||||
|
@pytest.mark.parametrize("eingabe", ["D", "D:", "d:", "D:\\", "d:/", " D: "])
|
||||||
|
def test_geraetepfad_ist_immer_dieselbe_form(eingabe):
|
||||||
|
assert w.geraetepfad(eingabe) == r"\\.\D:"
|
||||||
|
|
||||||
|
|
||||||
|
def test_geraetepfad_haengt_keinen_backslash_an():
|
||||||
|
"""Der entscheidende Punkt: MIT abschliessendem Backslash oeffnet
|
||||||
|
CreateFileW das WURZELVERZEICHNIS des Dateisystems statt des Geraets.
|
||||||
|
Die IOCTLs scheitern dann mit ERROR_INVALID_FUNCTION."""
|
||||||
|
assert not w.geraetepfad("E:\\").endswith("\\")
|
||||||
|
assert w.geraetepfad("E:\\") == r"\\.\E:"
|
||||||
|
|
||||||
|
|
||||||
|
def test_geraetepfad_ohne_angabe_wirft():
|
||||||
|
for leer in ("", " ", ":", "\\"):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
w.geraetepfad(leer)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drive_cdrom_ist_fuenf():
|
||||||
|
"""GetDriveTypeW meldet optische Laufwerke als 5. Ein Zahlendreher hier
|
||||||
|
hiesse: Rippy findet entweder gar kein Laufwerk oder haelt die
|
||||||
|
Systemplatte fuer eines."""
|
||||||
|
assert w.DRIVE_CDROM == 5
|
||||||
|
assert w.DRIVE_FIXED == 3
|
||||||
|
assert w.DRIVE_REMOVABLE == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_not_ready_ist_einundzwanzig():
|
||||||
|
"""Das ist die Antwort von CHECK_VERIFY2 bei leerem Laufwerk — und damit
|
||||||
|
die Windows-Entsprechung von CDS_NO_DISC. Sie ENTSCHEIDET, ob ein
|
||||||
|
Auswurf als geglueckt gilt."""
|
||||||
|
assert w.ERROR_NOT_READY == 21
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""Der Windows-Treiber gegen ein nachgebautes Laufwerk.
|
||||||
|
|
||||||
|
## Was diese Tests beweisen — und was nicht
|
||||||
|
|
||||||
|
**Sie beweisen den ABLAUF:** dass erst entriegelt und dann ausgeworfen wird,
|
||||||
|
dass danach nachgesehen statt geglaubt wird, dass ein verriegeltes Laufwerk als
|
||||||
|
Fehlschlag gilt, dass ein unzugängliches Laufwerk „unbekannt" ergibt und nicht
|
||||||
|
„leer", und dass beide Verträge (werfend / nicht werfend) stimmen.
|
||||||
|
|
||||||
|
**Sie beweisen NICHT, dass echte Hardware sich so verhält.** Dafür braucht es
|
||||||
|
ein angestecktes Laufwerk und eine Disc. Bis das gemessen ist, gilt der Treiber
|
||||||
|
als gebaut, nicht als bewiesen.
|
||||||
|
|
||||||
|
Das nachgebaute Laufwerk unten bildet genau das Verhalten ab, das am
|
||||||
|
26.07.2026 am echten BU40N unter Linux GEMESSEN wurde: Der Auswurf-Befehl wird
|
||||||
|
auch bei verriegelter Tür mit Erfolg quittiert und tut nichts. Es gibt keinen
|
||||||
|
Anlass anzunehmen, dass Windows das anders macht — beide Wege enden im selben
|
||||||
|
SCSI-Befehl an dasselbe Gerät.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rippy.drives import win_ioctl as w
|
||||||
|
from rippy.drives import windows
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLaufwerk:
|
||||||
|
"""Ein Laufwerk aus Papier — verhält sich wie das gemessene echte.
|
||||||
|
|
||||||
|
`verriegelt=True` bildet den Zustand nach einem MakeMKV-Rip ab: Die Tür ist
|
||||||
|
zu, und der Auswurf-Befehl wird trotzdem mit Erfolg quittiert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, medium=True, verriegelt=False, kennt_lockdoor=True,
|
||||||
|
buchstaben=("C", "D"), typen=None, disk_flags=None,
|
||||||
|
groesse=None, oeffnen_fehler=None):
|
||||||
|
self.medium = medium
|
||||||
|
self.verriegelt = verriegelt
|
||||||
|
self.kennt_lockdoor = kennt_lockdoor
|
||||||
|
self._buchstaben = list(buchstaben)
|
||||||
|
self._typen = typen or {"C": w.DRIVE_FIXED, "D": w.DRIVE_CDROM}
|
||||||
|
self.disk_flags = disk_flags
|
||||||
|
self.groesse = groesse
|
||||||
|
self.oeffnen_fehler = oeffnen_fehler
|
||||||
|
self.aufrufe = [] # Reihenfolge der Steuercodes
|
||||||
|
self.offen = 0
|
||||||
|
|
||||||
|
# -- Laufwerks-Auflistung
|
||||||
|
def laufwerksbuchstaben(self):
|
||||||
|
return list(self._buchstaben)
|
||||||
|
|
||||||
|
def laufwerkstyp(self, buchstabe):
|
||||||
|
return self._typen.get(buchstabe, w.DRIVE_UNKNOWN)
|
||||||
|
|
||||||
|
# -- Gerät
|
||||||
|
def oeffnen(self, pfad):
|
||||||
|
if self.oeffnen_fehler is not None:
|
||||||
|
raise self.oeffnen_fehler
|
||||||
|
self.offen += 1
|
||||||
|
return 4242
|
||||||
|
|
||||||
|
def schliessen(self, handle):
|
||||||
|
self.offen -= 1
|
||||||
|
|
||||||
|
def steuern(self, handle, code, eingabe=b"", ausgabe_bytes=0):
|
||||||
|
self.aufrufe.append(code)
|
||||||
|
|
||||||
|
if code == w.IOCTL_STORAGE_MEDIA_REMOVAL:
|
||||||
|
if not self.kennt_lockdoor:
|
||||||
|
raise windows.Win32Fehler("MEDIA_REMOVAL", w.ERROR_INVALID_FUNCTION)
|
||||||
|
self.verriegelt = eingabe[:1] == b"\x01"
|
||||||
|
return b""
|
||||||
|
|
||||||
|
if code == w.IOCTL_STORAGE_EJECT_MEDIA:
|
||||||
|
# ⚠️ Der gemessene Punkt: Erfolg wird IMMER gemeldet. Gewirkt hat
|
||||||
|
# er nur, wenn die Tuer nicht verriegelt ist.
|
||||||
|
if not self.verriegelt:
|
||||||
|
self.medium = False
|
||||||
|
return b""
|
||||||
|
|
||||||
|
if code == w.IOCTL_STORAGE_CHECK_VERIFY2:
|
||||||
|
if not self.medium:
|
||||||
|
raise windows.Win32Fehler("CHECK_VERIFY2", w.ERROR_NOT_READY)
|
||||||
|
return b""
|
||||||
|
|
||||||
|
if code == w.IOCTL_CDROM_DISK_TYPE:
|
||||||
|
if self.disk_flags is None:
|
||||||
|
raise windows.Win32Fehler("DISK_TYPE", w.ERROR_INVALID_FUNCTION)
|
||||||
|
return int(self.disk_flags).to_bytes(4, "little")
|
||||||
|
|
||||||
|
if code == w.IOCTL_DISK_GET_LENGTH_INFO:
|
||||||
|
if self.groesse is None:
|
||||||
|
raise windows.Win32Fehler("GET_LENGTH_INFO", w.ERROR_INVALID_FUNCTION)
|
||||||
|
return int(self.groesse).to_bytes(8, "little")
|
||||||
|
|
||||||
|
raise windows.Win32Fehler(f"unerwarteter Code {code:#x}", w.ERROR_INVALID_FUNCTION)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Laufwerke finden ────────────────────────────────────────────────────
|
||||||
|
def test_nur_optische_laufwerke_werden_gelistet():
|
||||||
|
"""Waeren es alle, stuende die Systemplatte als „Laufwerk" im UI."""
|
||||||
|
api = FakeLaufwerk(buchstaben=("C", "D", "E", "Z"), typen={
|
||||||
|
"C": w.DRIVE_FIXED, "D": w.DRIVE_CDROM,
|
||||||
|
"E": w.DRIVE_CDROM, "Z": w.DRIVE_REMOTE,
|
||||||
|
})
|
||||||
|
assert windows.list_optical_devices(api) == [r"\\.\D:", r"\\.\E:"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_optisches_laufwerk_ist_die_liste_leer():
|
||||||
|
api = FakeLaufwerk(buchstaben=("C",), typen={"C": w.DRIVE_FIXED})
|
||||||
|
assert windows.list_optical_devices(api) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auswurf: die Reihenfolge ist der Punkt ──────────────────────────────
|
||||||
|
def test_auswurf_entriegelt_zuerst_und_klappt_dann():
|
||||||
|
api = FakeLaufwerk(medium=True, verriegelt=True)
|
||||||
|
assert windows.auswerfen_versuchen(r"\\.\D:", api=api, warten=lambda s: None) is True
|
||||||
|
assert api.medium is False
|
||||||
|
# Entriegeln VOR Auswerfen — sonst quittiert das Laufwerk Erfolg und tut nichts.
|
||||||
|
assert api.aufrufe[0] == w.IOCTL_STORAGE_MEDIA_REMOVAL
|
||||||
|
assert api.aufrufe[1] == w.IOCTL_STORAGE_EJECT_MEDIA
|
||||||
|
|
||||||
|
|
||||||
|
def test_auswurf_meldet_fehlschlag_wenn_die_disc_drin_bleibt():
|
||||||
|
"""Der teure Fehler aus v1, hier vorweggenommen: Ein Laufwerk, das das
|
||||||
|
Entriegeln nicht kennt und verriegelt bleibt, quittiert den Auswurf
|
||||||
|
trotzdem mit Erfolg. Wer dem Rueckgabewert glaubt, schreibt „Disc
|
||||||
|
ausgeworfen" ins Log, waehrend die Schublade zu bleibt."""
|
||||||
|
api = FakeLaufwerk(medium=True, verriegelt=True, kennt_lockdoor=False)
|
||||||
|
assert windows.auswerfen_versuchen(r"\\.\D:", api=api, warten=lambda s: None) is False
|
||||||
|
assert api.medium is True # nie aufgegangen
|
||||||
|
|
||||||
|
|
||||||
|
def test_auswurf_ohne_laufwerk_wirft_nicht():
|
||||||
|
api = FakeLaufwerk(oeffnen_fehler=windows.Win32Fehler("CreateFileW", w.ERROR_FILE_NOT_FOUND))
|
||||||
|
assert windows.auswerfen_versuchen(r"\\.\Z:", api=api, warten=lambda s: None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_geraet_wird_immer_wieder_geschlossen():
|
||||||
|
"""Ein offenes Handle auf einem optischen Laufwerk blockiert den Auswurf
|
||||||
|
fuer JEDEN anderen — auch fuer den Explorer."""
|
||||||
|
api = FakeLaufwerk(medium=True)
|
||||||
|
windows.auswerfen_versuchen(r"\\.\D:", api=api, warten=lambda s: None)
|
||||||
|
assert api.offen == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Der andere Vertrag: eject() muss werfen ─────────────────────────────
|
||||||
|
def test_eject_ist_still_wenn_die_disc_rauskommt():
|
||||||
|
api = FakeLaufwerk(medium=True, verriegelt=True)
|
||||||
|
assert windows.eject(r"\\.\D:", api=api) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_eject_wirft_wenn_die_disc_drin_bleibt():
|
||||||
|
api = FakeLaufwerk(medium=True, verriegelt=True, kennt_lockdoor=False)
|
||||||
|
with pytest.raises(OSError, match="noch"):
|
||||||
|
windows.eject(r"\\.\D:", api=api)
|
||||||
|
|
||||||
|
|
||||||
|
def test_eject_reicht_den_urspruenglichen_fehler_weiter():
|
||||||
|
"""„Zugriff verweigert" und „Gerät nicht gefunden" sind zwei ganz
|
||||||
|
verschiedene Probleme. Wer sie hinter einer eigenen Meldung versteckt,
|
||||||
|
nimmt dem Nutzer die einzige brauchbare Auskunft."""
|
||||||
|
original = windows.Win32Fehler("CreateFileW", w.ERROR_ACCESS_DENIED)
|
||||||
|
api = FakeLaufwerk(oeffnen_fehler=original)
|
||||||
|
with pytest.raises(OSError) as fehler:
|
||||||
|
windows.eject(r"\\.\D:", api=api)
|
||||||
|
assert fehler.value is original
|
||||||
|
# `code` statt `winerror`: letzteres gibt es nur unter Windows, und der
|
||||||
|
# Treiber wird auch anderswo getestet.
|
||||||
|
assert fehler.value.code == w.ERROR_ACCESS_DENIED
|
||||||
|
|
||||||
|
|
||||||
|
# ── Zustand und Typ ─────────────────────────────────────────────────────
|
||||||
|
def test_leeres_laufwerk_ist_leer_und_nicht_unbekannt():
|
||||||
|
api = FakeLaufwerk(medium=False)
|
||||||
|
assert windows.detect_disc_type(r"\\.\D:", api=api) == "no_disc"
|
||||||
|
assert windows.device_info(r"\\.\D:", api=api)["status"] == "empty"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unzugaengliches_laufwerk_ist_UNBEKANNT_und_nicht_leer():
|
||||||
|
"""DIE Regel des Ports. Ein Laufwerk, das gerade belegt ist, hat nicht
|
||||||
|
„keine Disc" — man weiss es nur nicht. In v1 hat genau diese Vermischung
|
||||||
|
Listen im UI geleert."""
|
||||||
|
api = FakeLaufwerk(oeffnen_fehler=windows.Win32Fehler("CreateFileW", w.ERROR_ACCESS_DENIED))
|
||||||
|
assert windows.detect_disc_type(r"\\.\D:", api=api) == "unknown"
|
||||||
|
assert windows.device_info(r"\\.\D:", api=api)["status"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("flags,groesse,erwartet", [
|
||||||
|
(w.CDROM_DISK_AUDIO_TRACK, 700 * 1024**2, "cd"),
|
||||||
|
(w.CDROM_DISK_DATA_TRACK, 8 * 1024**3, "dvd"),
|
||||||
|
(w.CDROM_DISK_DATA_TRACK, 25 * 1024**3, "bluray"),
|
||||||
|
(w.CDROM_DISK_DATA_TRACK, 50 * 1000**3, "bluray"), # BD-50
|
||||||
|
(w.CDROM_DISK_DATA_TRACK, 66 * 1000**3, "uhd"), # BD-66
|
||||||
|
(w.CDROM_DISK_DATA_TRACK, 100 * 1000**3, "uhd"),
|
||||||
|
])
|
||||||
|
def test_disc_typ_kommt_aus_derselben_zuordnung_wie_unter_linux(flags, groesse, erwartet):
|
||||||
|
"""Die Einordnung liegt in cdrom.classify() — EINE Funktion fuer beide
|
||||||
|
Plattformen, und die ist eigenstaendig getestet. Hier wird nur geprueft,
|
||||||
|
dass Windows die beiden Eingangswerte richtig beschafft."""
|
||||||
|
api = FakeLaufwerk(medium=True, disk_flags=flags, groesse=groesse)
|
||||||
|
assert windows.detect_disc_type(r"\\.\D:", api=api) == erwartet
|
||||||
|
|
||||||
|
|
||||||
|
def test_verriegeln_schickt_das_richtige_byte():
|
||||||
|
api = FakeLaufwerk(medium=True)
|
||||||
|
windows.verriegeln(r"\\.\D:", True, api=api)
|
||||||
|
assert api.verriegelt is True
|
||||||
|
windows.verriegeln(r"\\.\D:", False, api=api)
|
||||||
|
assert api.verriegelt is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_info_hat_dieselben_felder_wie_unter_linux():
|
||||||
|
"""Das UI unterscheidet nicht nach Plattform — fehlt ein Feld, bleibt die
|
||||||
|
Karte im Dashboard leer."""
|
||||||
|
api = FakeLaufwerk(medium=True, disk_flags=w.CDROM_DISK_DATA_TRACK, groesse=25 * 1024**3)
|
||||||
|
eintrag = windows.device_info(r"\\.\D:", api=api)
|
||||||
|
assert set(eintrag) == {"id", "name", "type", "path", "status", "model", "serial"}
|
||||||
|
assert eintrag["id"] == "D"
|
||||||
|
assert eintrag["status"] == "ready"
|
||||||
|
assert eintrag["type"] == "bluray"
|
||||||
|
|
||||||
|
|
||||||
|
def test_treiber_verweigert_sich_ausserhalb_von_windows():
|
||||||
|
"""Sonst waere der erste Fehler ein kryptischer ctypes-Absturz statt
|
||||||
|
einer Ansage."""
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
|
if _sys.platform.startswith("win"):
|
||||||
|
pytest.skip("laeuft nur, wenn NICHT unter Windows getestet wird")
|
||||||
|
with pytest.raises(RuntimeError, match="nur unter Windows"):
|
||||||
|
windows.Win32()
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Die Win32-Steuercodes für optische Laufwerke — hergeleitet, nicht abgeschrieben.
|
||||||
|
|
||||||
|
## Warum sie hier ausgerechnet werden
|
||||||
|
|
||||||
|
`AGENTS.md` Regel D: *„Externe Schnittstellen NIE aus dem Kopf. Vor Nutzung
|
||||||
|
fremder CLI-Flags oder Bibliotheks-APIs: --help/Doku prüfen und die Fundstelle
|
||||||
|
im Commit nennen."*
|
||||||
|
|
||||||
|
Zahlen wie `0x2D4808` sind genau der Fall, in dem sich ein Zahlendreher
|
||||||
|
lautlos versteckt: Ein falscher Code liefert kein „unbekannter Befehl", sondern
|
||||||
|
`ERROR_INVALID_FUNCTION` — und das sieht aus wie „das Laufwerk kann das nicht".
|
||||||
|
Man sucht dann am Gerät statt an der Zahl.
|
||||||
|
|
||||||
|
Deshalb steht hier das Makro aus `winioctl.h` als Funktion, und jede Konstante
|
||||||
|
wird daraus gebildet. Wer nachrechnen will, sieht die Bestandteile.
|
||||||
|
|
||||||
|
#define CTL_CODE(DeviceType, Function, Method, Access) ( \\
|
||||||
|
((DeviceType) << 16) | ((Access) << 14) | \\
|
||||||
|
((Function) << 2) | (Method) \\
|
||||||
|
)
|
||||||
|
|
||||||
|
Fundstellen (Windows SDK / Microsoft Learn):
|
||||||
|
* CTL_CODE, FILE_DEVICE_*, METHOD_*, FILE_*_ACCESS — winioctl.h
|
||||||
|
* IOCTL_STORAGE_CHECK_VERIFY2 — learn.microsoft.com/windows/win32/api/
|
||||||
|
winioctl/ni-winioctl-ioctl_storage_check_verify2
|
||||||
|
* IOCTL_STORAGE_MEDIA_REMOVAL / _EJECT_MEDIA / _LOAD_MEDIA — ebenda
|
||||||
|
* IOCTL_CDROM_DISK_TYPE, CDROM_DISK_AUDIO_TRACK/DATA_TRACK — ntddcdrm.h
|
||||||
|
* IOCTL_DISK_GET_LENGTH_INFO — winioctl.h (GET_LENGTH_INFORMATION)
|
||||||
|
|
||||||
|
## Gegenprobe
|
||||||
|
|
||||||
|
`test_win_ioctl.py` rechnet die hier gebildeten Werte gegen die Zahlen, die in
|
||||||
|
der Microsoft-Dokumentation stehen. Stimmt eine nicht, faellt das beim Testen
|
||||||
|
auf — auf JEDER Plattform, auch ohne Windows und ohne Laufwerk.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Bausteine aus winioctl.h ────────────────────────────────────────────
|
||||||
|
FILE_DEVICE_CD_ROM = 0x00000002
|
||||||
|
FILE_DEVICE_DISK = 0x00000007
|
||||||
|
FILE_DEVICE_MASS_STORAGE = 0x0000002D # = IOCTL_STORAGE_BASE
|
||||||
|
|
||||||
|
METHOD_BUFFERED = 0
|
||||||
|
METHOD_IN_DIRECT = 1
|
||||||
|
METHOD_OUT_DIRECT = 2
|
||||||
|
METHOD_NEITHER = 3
|
||||||
|
|
||||||
|
FILE_ANY_ACCESS = 0
|
||||||
|
FILE_READ_ACCESS = 0x0001
|
||||||
|
FILE_WRITE_ACCESS = 0x0002
|
||||||
|
|
||||||
|
|
||||||
|
def ctl_code(geraetetyp: int, funktion: int, methode: int, zugriff: int) -> int:
|
||||||
|
"""Das CTL_CODE-Makro aus winioctl.h, eins zu eins."""
|
||||||
|
return ((geraetetyp << 16) | (zugriff << 14) | (funktion << 2) | methode)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Speicher (IOCTL_STORAGE_*) ──────────────────────────────────────────
|
||||||
|
# „Ist ein Medium eingelegt?" — die Windows-Entsprechung von
|
||||||
|
# CDROM_DRIVE_STATUS. Antwortet mit ERROR_NOT_READY (21), wenn nichts drin ist.
|
||||||
|
IOCTL_STORAGE_CHECK_VERIFY2 = ctl_code(
|
||||||
|
FILE_DEVICE_MASS_STORAGE, 0x0200, METHOD_BUFFERED, FILE_ANY_ACCESS)
|
||||||
|
|
||||||
|
# Tür verriegeln/entriegeln — die Entsprechung von CDROM_LOCKDOOR.
|
||||||
|
# Erwartet eine PREVENT_MEDIA_REMOVAL-Struktur (ein BOOLEAN).
|
||||||
|
IOCTL_STORAGE_MEDIA_REMOVAL = ctl_code(
|
||||||
|
FILE_DEVICE_MASS_STORAGE, 0x0201, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||||
|
|
||||||
|
# Auswerfen — die Entsprechung von CDROMEJECT.
|
||||||
|
IOCTL_STORAGE_EJECT_MEDIA = ctl_code(
|
||||||
|
FILE_DEVICE_MASS_STORAGE, 0x0202, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||||
|
|
||||||
|
# Schublade einziehen — die Entsprechung von CDROMCLOSETRAY.
|
||||||
|
IOCTL_STORAGE_LOAD_MEDIA = ctl_code(
|
||||||
|
FILE_DEVICE_MASS_STORAGE, 0x0203, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||||
|
|
||||||
|
# ── CD-ROM (IOCTL_CDROM_*) ──────────────────────────────────────────────
|
||||||
|
# Audio- oder Datenspur? Grob, aber genau das, was CDROM_DISC_STATUS auch
|
||||||
|
# liefert — mehr braucht die Einordnung nicht (die Feinunterscheidung
|
||||||
|
# DVD/BD/UHD kommt über die Größe).
|
||||||
|
IOCTL_CDROM_DISK_TYPE = ctl_code(
|
||||||
|
FILE_DEVICE_CD_ROM, 0x0010, METHOD_BUFFERED, FILE_ANY_ACCESS)
|
||||||
|
|
||||||
|
CDROM_DISK_AUDIO_TRACK = 0x00000001
|
||||||
|
CDROM_DISK_DATA_TRACK = 0x00000002
|
||||||
|
|
||||||
|
# ── Größe des Mediums ───────────────────────────────────────────────────
|
||||||
|
# Die Entsprechung von BLKGETSIZE64. Liefert GET_LENGTH_INFORMATION
|
||||||
|
# (ein LARGE_INTEGER, also 8 Byte).
|
||||||
|
IOCTL_DISK_GET_LENGTH_INFO = ctl_code(
|
||||||
|
FILE_DEVICE_DISK, 0x0017, METHOD_BUFFERED, FILE_READ_ACCESS)
|
||||||
|
|
||||||
|
# ── Rückmeldungen von GetLastError() ────────────────────────────────────
|
||||||
|
ERROR_NOT_READY = 21 # kein Medium eingelegt
|
||||||
|
ERROR_ACCESS_DENIED = 5
|
||||||
|
ERROR_FILE_NOT_FOUND = 2
|
||||||
|
ERROR_INVALID_FUNCTION = 1 # Gerät kennt diesen Steuercode nicht
|
||||||
|
ERROR_MEDIA_CHANGED = 1110
|
||||||
|
ERROR_NO_MEDIA_IN_DRIVE = 1112
|
||||||
|
|
||||||
|
# ── GetDriveTypeW ───────────────────────────────────────────────────────
|
||||||
|
DRIVE_UNKNOWN = 0
|
||||||
|
DRIVE_NO_ROOT_DIR = 1
|
||||||
|
DRIVE_REMOVABLE = 2
|
||||||
|
DRIVE_FIXED = 3
|
||||||
|
DRIVE_REMOTE = 4
|
||||||
|
DRIVE_CDROM = 5
|
||||||
|
DRIVE_RAMDISK = 6
|
||||||
|
|
||||||
|
# ── CreateFileW ─────────────────────────────────────────────────────────
|
||||||
|
GENERIC_READ = 0x80000000
|
||||||
|
FILE_SHARE_READ = 0x00000001
|
||||||
|
FILE_SHARE_WRITE = 0x00000002
|
||||||
|
OPEN_EXISTING = 3
|
||||||
|
INVALID_HANDLE_VALUE = -1
|
||||||
|
|
||||||
|
|
||||||
|
def geraetepfad(buchstabe: str) -> str:
|
||||||
|
r"""„D:" oder „D" oder „D:\" -> r"\\.\D:"
|
||||||
|
|
||||||
|
Der Win32-Gerätepfad hat KEINEN abschließenden Backslash. Mit einem
|
||||||
|
öffnet `CreateFileW` das Wurzelverzeichnis des Dateisystems statt des
|
||||||
|
Geräts — die IOCTLs scheitern dann mit ERROR_INVALID_FUNCTION, was wie
|
||||||
|
„das Laufwerk kann das nicht" aussieht.
|
||||||
|
"""
|
||||||
|
kern = (buchstabe or "").strip().rstrip("\\/").rstrip(":")
|
||||||
|
if not kern:
|
||||||
|
raise ValueError("Kein Laufwerksbuchstabe angegeben")
|
||||||
|
return "\\\\.\\" + kern[0].upper() + ":"
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"""Laufwerks-Treiber für Windows. Erfüllt `rippy.ports.Drives`.
|
||||||
|
|
||||||
|
Dieselben Verträge wie `linux.py`, nur mit Win32 statt ioctl:
|
||||||
|
|
||||||
|
auswerfen_versuchen(geraet) -> bool gibt False zurück, wirft nie
|
||||||
|
eject(geraet) -> None wirft OSError
|
||||||
|
|
||||||
|
## Warum ctypes und nicht pywin32
|
||||||
|
|
||||||
|
pywin32 wäre eine zusätzliche Abhängigkeit von rund 20 MB, die in ein
|
||||||
|
PyInstaller-Paket mit hinein muss und bei jedem Python-Wechsel neu passen
|
||||||
|
will. Alles, was hier gebraucht wird, sind fünf Funktionen aus `kernel32.dll` —
|
||||||
|
die spricht `ctypes` direkt an, ohne irgendetwas mitzuliefern.
|
||||||
|
|
||||||
|
## Die zwei Regeln des Ports gelten hier genauso
|
||||||
|
|
||||||
|
**1. Auswerfen heißt: entriegeln, auswerfen, NACHSEHEN.** Unter Linux wurde am
|
||||||
|
26.07.2026 gemessen, dass ein verriegeltes Laufwerk den Auswurf mit ERFOLG
|
||||||
|
quittiert und nichts tut — MakeMKV verriegelt die Tür während des Rips und
|
||||||
|
entriegelt sie nicht wieder. Es gibt keinen Grund anzunehmen, dass Windows das
|
||||||
|
anders handhabt: `IOCTL_STORAGE_EJECT_MEDIA` meldet ebenfalls nur, dass der
|
||||||
|
Befehl angenommen wurde. Deshalb wird auch hier nachgesehen — der Beweis ist
|
||||||
|
`IOCTL_STORAGE_CHECK_VERIFY2`, das bei leerem Laufwerk `ERROR_NOT_READY`
|
||||||
|
liefert.
|
||||||
|
|
||||||
|
**2. Ein unbekannter Zustand heißt UNBEKANNT, nicht leer.** Wenn sich das Gerät
|
||||||
|
nicht öffnen lässt oder ein Steuercode scheitert, ist die Antwort „ich weiß es
|
||||||
|
nicht" — nicht „da ist keine Disc". Genau diese Vermischung hat in v1 die
|
||||||
|
Listen im UI geleert.
|
||||||
|
|
||||||
|
## Was hier geprüft ist und was nicht
|
||||||
|
|
||||||
|
**Geprüft, ohne Laufwerk:** die Steuercodes (`test_win_ioctl.py` rechnet sie
|
||||||
|
gegen die Dokumentation), der Ablauf des Auswurfs, die Fehlerunterscheidung,
|
||||||
|
die Typ-Zuordnung — alles über eine eingespritzte Win32-Nachbildung
|
||||||
|
(`test_windows.py`). Diese Tests laufen auf jeder Plattform.
|
||||||
|
|
||||||
|
**NICHT geprüft:** dass echte Hardware sich so verhält. Dafür braucht es ein
|
||||||
|
angestecktes Laufwerk und eine Disc. Solange das nicht gemessen ist, gilt
|
||||||
|
dieser Treiber als GEBAUT, nicht als BEWIESEN — die Unterscheidung steht so
|
||||||
|
in AGENTS.md („Beweisen statt behaupten").
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from rippy.drives import win_ioctl as w
|
||||||
|
from rippy.drives.cdrom import (
|
||||||
|
CDS_AUDIO,
|
||||||
|
CDS_DATA_1,
|
||||||
|
CDS_DISC_OK,
|
||||||
|
CDS_NO_DISC,
|
||||||
|
classify,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wie lange nach dem Auswurf-Befehl auf die Schublade gewartet wird.
|
||||||
|
# Gleicher Wert wie unter Linux — ein Laufwerk braucht ein bis zwei Sekunden.
|
||||||
|
AUSWURF_WARTEN_SEKUNDEN = 5
|
||||||
|
|
||||||
|
# Ergebnisse von auswerfen_mit_grund — dieselben wie im Linux-Treiber, damit
|
||||||
|
# aufrufender Code nicht wissen muss, auf welcher Plattform er läuft.
|
||||||
|
AUSWURF_OK = "ok"
|
||||||
|
AUSWURF_KEIN_ZUGRIFF = "kein-zugriff"
|
||||||
|
AUSWURF_ABGELEHNT = "abgelehnt"
|
||||||
|
AUSWURF_BLEIBT_DRIN = "bleibt-drin"
|
||||||
|
|
||||||
|
|
||||||
|
class Win32Fehler(OSError):
|
||||||
|
"""Ein fehlgeschlagener Win32-Aufruf, mit dem Code von GetLastError().
|
||||||
|
|
||||||
|
⚠️ Die Zuweisung MUSS nach `super().__init__()` stehen. Nachgemessen:
|
||||||
|
Ein zweiargumentiges `OSError.__init__(code, text)` setzt `winerror`
|
||||||
|
wieder auf `None` — ein davor gesetzter Wert ist danach fort. Genau
|
||||||
|
daran sind beim Bau dieser Datei vier Tests gescheitert: `_medium_da`
|
||||||
|
erkannte `ERROR_NOT_READY` nicht mehr, hielt jedes leere Laufwerk für
|
||||||
|
einen echten Fehler und meldete „unbekannt" statt „leer".
|
||||||
|
|
||||||
|
`code` steht zusätzlich als eigener Name bereit — `winerror` gibt es nur
|
||||||
|
unter Windows, und dieser Treiber wird auch anderswo getestet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, was: str, code: int):
|
||||||
|
super().__init__(code, f"{was} scheiterte (Win32-Fehler {code})")
|
||||||
|
self.code = code
|
||||||
|
self.winerror = code
|
||||||
|
|
||||||
|
|
||||||
|
class Win32:
|
||||||
|
"""Die echten kernel32-Aufrufe.
|
||||||
|
|
||||||
|
In Tests wird stattdessen eine Nachbildung eingesetzt — deshalb steckt
|
||||||
|
hier NUR die Anbindung und keine Entscheidung. Was der Treiber tut, steht
|
||||||
|
in den Funktionen darunter und ist damit ohne Windows prüfbar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if not sys.platform.startswith("win"):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Der Windows-Treiber laeuft nur unter Windows. Auf Linux ist "
|
||||||
|
"rippy.drives.linux zustaendig."
|
||||||
|
)
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
self._ctypes = ctypes
|
||||||
|
self._wintypes = wintypes
|
||||||
|
self._k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
|
|
||||||
|
# ── Laufwerke finden ────────────────────────────────────────────────
|
||||||
|
def laufwerksbuchstaben(self) -> list:
|
||||||
|
maske = self._k32.GetLogicalDrives()
|
||||||
|
return [chr(ord("A") + i) for i in range(26) if maske & (1 << i)]
|
||||||
|
|
||||||
|
def laufwerkstyp(self, buchstabe: str) -> int:
|
||||||
|
return self._k32.GetDriveTypeW(f"{buchstabe}:\\")
|
||||||
|
|
||||||
|
# ── Gerät öffnen ────────────────────────────────────────────────────
|
||||||
|
def oeffnen(self, geraetepfad: str):
|
||||||
|
handle = self._k32.CreateFileW(
|
||||||
|
geraetepfad,
|
||||||
|
w.GENERIC_READ,
|
||||||
|
w.FILE_SHARE_READ | w.FILE_SHARE_WRITE,
|
||||||
|
None,
|
||||||
|
w.OPEN_EXISTING,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if handle == w.INVALID_HANDLE_VALUE or handle in (0, None):
|
||||||
|
raise Win32Fehler("CreateFileW", self._ctypes.get_last_error())
|
||||||
|
return handle
|
||||||
|
|
||||||
|
def schliessen(self, handle) -> None:
|
||||||
|
self._k32.CloseHandle(handle)
|
||||||
|
|
||||||
|
# ── Steuercode senden ───────────────────────────────────────────────
|
||||||
|
def steuern(self, handle, code: int, eingabe: bytes = b"",
|
||||||
|
ausgabe_bytes: int = 0) -> bytes:
|
||||||
|
"""DeviceIoControl. Wirft Win32Fehler, gibt sonst die Antwort zurück."""
|
||||||
|
ctypes = self._ctypes
|
||||||
|
ein_puffer = ctypes.create_string_buffer(eingabe) if eingabe else None
|
||||||
|
aus_puffer = ctypes.create_string_buffer(ausgabe_bytes) if ausgabe_bytes else None
|
||||||
|
zurueck = self._wintypes.DWORD(0)
|
||||||
|
erfolg = self._k32.DeviceIoControl(
|
||||||
|
handle, code,
|
||||||
|
ein_puffer, len(eingabe),
|
||||||
|
aus_puffer, ausgabe_bytes,
|
||||||
|
ctypes.byref(zurueck), None,
|
||||||
|
)
|
||||||
|
if not erfolg:
|
||||||
|
raise Win32Fehler(f"DeviceIoControl({code:#x})", ctypes.get_last_error())
|
||||||
|
return aus_puffer.raw[:zurueck.value] if aus_puffer else b""
|
||||||
|
|
||||||
|
|
||||||
|
def _api(api):
|
||||||
|
return api if api is not None else Win32()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Laufwerke finden ────────────────────────────────────────────────────
|
||||||
|
def list_optical_devices(api=None) -> list:
|
||||||
|
"""Alle optischen Laufwerke — als Gerätepfade wie r"\\\\.\\D:".
|
||||||
|
|
||||||
|
Kein Scan über alle 26 Buchstaben mit Öffnen-Versuch: `GetDriveTypeW`
|
||||||
|
beantwortet die Frage ohne jeden Zugriff auf das Gerät. Ein Öffnen-Versuch
|
||||||
|
auf einem leeren Laufwerk kann sonst mehrere Sekunden dauern (das Laufwerk
|
||||||
|
fährt hoch), und das mal 26.
|
||||||
|
"""
|
||||||
|
api = _api(api)
|
||||||
|
return sorted(
|
||||||
|
w.geraetepfad(b) for b in api.laufwerksbuchstaben()
|
||||||
|
if api.laufwerkstyp(b) == w.DRIVE_CDROM
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Zustand ─────────────────────────────────────────────────────────────
|
||||||
|
def _medium_da(api, handle) -> bool:
|
||||||
|
"""Liegt eine Disc ein? Wirft Win32Fehler bei allem außer NOT_READY."""
|
||||||
|
try:
|
||||||
|
api.steuern(handle, w.IOCTL_STORAGE_CHECK_VERIFY2)
|
||||||
|
return True
|
||||||
|
except Win32Fehler as e:
|
||||||
|
if e.code in (w.ERROR_NOT_READY, w.ERROR_NO_MEDIA_IN_DRIVE):
|
||||||
|
return False
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def drive_status(geraet: str, api=None) -> int:
|
||||||
|
"""Wie `linux.detection.drive_status`: CDS_DISC_OK oder CDS_NO_DISC.
|
||||||
|
|
||||||
|
Wirft, wenn der Zustand NICHT feststellbar ist. Der Aufrufer muss
|
||||||
|
entscheiden, was er damit macht — ein stilles „leer" wäre eine falsche
|
||||||
|
Aussage über die Welt.
|
||||||
|
"""
|
||||||
|
api = _api(api)
|
||||||
|
handle = api.oeffnen(geraet)
|
||||||
|
try:
|
||||||
|
return CDS_DISC_OK if _medium_da(api, handle) else CDS_NO_DISC
|
||||||
|
finally:
|
||||||
|
api.schliessen(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def disc_status(geraet: str, api=None) -> int:
|
||||||
|
"""Audio- oder Datenträger — übersetzt in die CDS_*-Werte von cdrom.py.
|
||||||
|
|
||||||
|
Damit passt das Ergebnis in dieselbe `classify()`, die der Linux-Treiber
|
||||||
|
benutzt: EINE Zuordnungslogik für beide Plattformen, und die ist getestet.
|
||||||
|
"""
|
||||||
|
api = _api(api)
|
||||||
|
handle = api.oeffnen(geraet)
|
||||||
|
try:
|
||||||
|
antwort = api.steuern(handle, w.IOCTL_CDROM_DISK_TYPE, ausgabe_bytes=4)
|
||||||
|
flags = int.from_bytes(antwort[:4], "little") if antwort else 0
|
||||||
|
finally:
|
||||||
|
api.schliessen(handle)
|
||||||
|
if flags & w.CDROM_DISK_AUDIO_TRACK:
|
||||||
|
return CDS_AUDIO
|
||||||
|
if flags & w.CDROM_DISK_DATA_TRACK:
|
||||||
|
return CDS_DATA_1
|
||||||
|
return 0 # unbekannt -> classify() sagt "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def disc_size_bytes(geraet: str, api=None) -> int:
|
||||||
|
"""Größe des eingelegten Mediums — die Entsprechung von BLKGETSIZE64.
|
||||||
|
|
||||||
|
Sie entscheidet zwischen DVD, Blu-ray und 4K-UHD (Schwellen in cdrom.py).
|
||||||
|
"""
|
||||||
|
api = _api(api)
|
||||||
|
handle = api.oeffnen(geraet)
|
||||||
|
try:
|
||||||
|
antwort = api.steuern(handle, w.IOCTL_DISK_GET_LENGTH_INFO, ausgabe_bytes=8)
|
||||||
|
return int.from_bytes(antwort[:8], "little") if antwort else 0
|
||||||
|
finally:
|
||||||
|
api.schliessen(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_disc_type(geraet: str, api=None) -> str:
|
||||||
|
"""cd | dvd | bluray | uhd | no_disc | unknown."""
|
||||||
|
api = _api(api)
|
||||||
|
try:
|
||||||
|
if drive_status(geraet, api) != CDS_DISC_OK:
|
||||||
|
return "no_disc"
|
||||||
|
return classify(disc_status(geraet, api), disc_size_bytes(geraet, api))
|
||||||
|
except OSError:
|
||||||
|
# „konnte nicht nachsehen" — ausdruecklich NICHT "no_disc".
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Verriegeln ──────────────────────────────────────────────────────────
|
||||||
|
def verriegeln(geraet: str, an: bool, api=None) -> None:
|
||||||
|
"""Tür verriegeln (True) oder entriegeln (False).
|
||||||
|
|
||||||
|
`PREVENT_MEDIA_REMOVAL` ist eine Struktur mit genau einem BOOLEAN.
|
||||||
|
"""
|
||||||
|
api = _api(api)
|
||||||
|
handle = api.oeffnen(geraet)
|
||||||
|
try:
|
||||||
|
api.steuern(handle, w.IOCTL_STORAGE_MEDIA_REMOVAL,
|
||||||
|
eingabe=b"\x01" if an else b"\x00")
|
||||||
|
finally:
|
||||||
|
api.schliessen(handle)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auswerfen ───────────────────────────────────────────────────────────
|
||||||
|
def auswerfen_mit_grund(geraet: str, api=None, warten=None):
|
||||||
|
"""Die gemeinsame Mechanik. Wirft NIE.
|
||||||
|
|
||||||
|
Rückgabe `(grund, fehler)` wie im Linux-Treiber. Reihenfolge ist
|
||||||
|
dieselbe und aus demselben Grund: **entriegeln, auswerfen, nachsehen.**
|
||||||
|
"""
|
||||||
|
api = _api(api)
|
||||||
|
if warten is None:
|
||||||
|
import time
|
||||||
|
warten = time.sleep
|
||||||
|
|
||||||
|
try:
|
||||||
|
handle = api.oeffnen(geraet)
|
||||||
|
except OSError as e:
|
||||||
|
return AUSWURF_KEIN_ZUGRIFF, e
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Entriegeln zuerst. Scheitert es, wird der Auswurf trotzdem versucht —
|
||||||
|
# bei einem nicht verriegelten Laufwerk klappt er ohnehin.
|
||||||
|
try:
|
||||||
|
api.steuern(handle, w.IOCTL_STORAGE_MEDIA_REMOVAL, eingabe=b"\x00")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
api.steuern(handle, w.IOCTL_STORAGE_EJECT_MEDIA)
|
||||||
|
except OSError as e:
|
||||||
|
return AUSWURF_ABGELEHNT, e
|
||||||
|
|
||||||
|
# NACHSEHEN. Der Befehl oben meldet nur, dass er angenommen wurde.
|
||||||
|
for _ in range(AUSWURF_WARTEN_SEKUNDEN):
|
||||||
|
try:
|
||||||
|
if not _medium_da(api, handle):
|
||||||
|
return AUSWURF_OK, None
|
||||||
|
except OSError as e:
|
||||||
|
return AUSWURF_ABGELEHNT, e
|
||||||
|
warten(1)
|
||||||
|
return AUSWURF_BLEIBT_DRIN, None
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
api.schliessen(handle)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def auswerfen_versuchen(geraet: str, api=None, warten=None) -> bool:
|
||||||
|
"""Wirft die Disc aus und prüft nach. Wirft NIE — Vertrag des Workers."""
|
||||||
|
grund, _ = auswerfen_mit_grund(geraet, api=api, warten=warten)
|
||||||
|
return grund == AUSWURF_OK
|
||||||
|
|
||||||
|
|
||||||
|
def eject(geraet: str, api=None) -> None:
|
||||||
|
"""Wirft die Disc aus. Wirft OSError, wenn sie drin bleibt — Vertrag der API."""
|
||||||
|
grund, fehler = auswerfen_mit_grund(geraet, api=api)
|
||||||
|
if grund == AUSWURF_OK:
|
||||||
|
return
|
||||||
|
if fehler is not None:
|
||||||
|
raise fehler
|
||||||
|
if grund == AUSWURF_KEIN_ZUGRIFF:
|
||||||
|
raise OSError(f"Das Laufwerk {geraet} ließ sich nicht ansprechen.")
|
||||||
|
raise OSError(
|
||||||
|
"Das Laufwerk hat den Auswurf angenommen, die Disc ist aber noch "
|
||||||
|
"drin. Blockiert etwas die Schublade, oder läuft noch ein Zugriff?"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Für das UI ──────────────────────────────────────────────────────────
|
||||||
|
def device_info(geraet: str, api=None) -> dict:
|
||||||
|
"""Der Geräte-Eintrag fürs UI — gleiche Felder wie beim Linux-Treiber.
|
||||||
|
|
||||||
|
`status` kennt hier drei Werte, nicht zwei: `ready`, `empty` und
|
||||||
|
**`unknown`**. Der dritte ist Absicht — ein Laufwerk, das sich nicht
|
||||||
|
ansprechen lässt (belegt, gerade angesteckt, defekt), ist nicht leer.
|
||||||
|
"""
|
||||||
|
api = _api(api)
|
||||||
|
status = "unknown"
|
||||||
|
disc_typ = "unknown"
|
||||||
|
try:
|
||||||
|
if drive_status(geraet, api) == CDS_DISC_OK:
|
||||||
|
status = "ready"
|
||||||
|
try:
|
||||||
|
disc_typ = classify(disc_status(geraet, api),
|
||||||
|
disc_size_bytes(geraet, api))
|
||||||
|
except OSError:
|
||||||
|
disc_typ = "unknown"
|
||||||
|
else:
|
||||||
|
status = "empty"
|
||||||
|
except OSError:
|
||||||
|
status = "unknown"
|
||||||
|
|
||||||
|
buchstabe = geraet.rstrip(":").rsplit("\\", 1)[-1].rstrip(":")
|
||||||
|
return {
|
||||||
|
"id": buchstabe,
|
||||||
|
"name": f"Laufwerk {buchstabe}:",
|
||||||
|
"type": disc_typ,
|
||||||
|
"path": geraet,
|
||||||
|
"status": status,
|
||||||
|
"model": "",
|
||||||
|
"serial": "",
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user