feat(v5): Etappe W-1 — Laufwerk: Wache, Typ, Auswerfen mit Nachsehen
Ampel / ampel (push) Successful in 1m21s

kern/laufwerk in drei Schichten: codes.ts (Steuercodes HERGELEITET wie
win_ioctl.py, gegen die Doku-Zahlen getestet), win32.ts (der einzige
koffi-Ort im Kern, Muster aus beweise/laufwerk.js), disc.ts (Logik gegen
die LaufwerkApi-Schnittstelle — classify mit den cdrom.py-Schwellen,
Geraete-Info mit ready/empty/unknown + Klartext-Grund, Auswurf als
entriegeln→auswerfen→NACHSEHEN mit injizierbarem Warten). Dazu wache.ts
im 3-Sekunden-Takt (§ 5 Plan A): Uebergangslogik pur, gescheiterte Runde
behaelt den Stand (R2) und meldet laut (R4).

Fenster zeigt Laufwerke live (Modell, Typ, Groesse, Grund) mit
Auswerfen-Knopf; Ereigniszeilen fuer Disc rein/raus und Fehler.

Bündel-Falle gefunden und behoben: Ein woertliches require überlebt
Rollup nicht — win32 wird per dynamischem import() als eigener Chunk
gebaut; der Fehler war dank R4 im Fenster sichtbar statt still.

Gemessen am echten BU40N (30.08.2026, RIPPY_MESSUNG=1):
  Laufwerk G: status=ready typ=bluray groesse=33759690752
  modell=HL-DT-ST BD-RE BU40N 1.03 serial=0025114C0149
Der Auswurf-Beweis (messung.auswurf) folgt am Ende der Sitzung — der
BU40N kann die Schublade nicht selbst einziehen.

52 Tests gruen (Steuercodes, classify, Auswurf-Ablauf, Geraete-Info,
Wache, Smoke weiterhin gruen mit Laufwerks-Kachel im Bild).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-30 17:28:46 +02:00
co-authored by Claude Fable 5
parent 6ed8035dcc
commit a45069af5e
13 changed files with 1377 additions and 73 deletions
+136 -45
View File
@@ -1,9 +1,9 @@
// W-0-Statusbild: zeigt, dass die drei Prozesse stehen und miteinander
// reden. Die echte Oberfläche (Dashboard, Bibliothek, Einstellungen) ist
// Etappe W-5 — hier geht es nur darum, das Gerüst SICHTBAR zu beweisen.
// Statusbild W-0/W-1: die drei Prozesse plus die echten Laufwerke aus der
// Disc-Wache. Die große Oberfläche (Dashboard, Bibliothek, Einstellungen)
// ist Etappe W-5 — hier wird das Gerüst SICHTBAR bewiesen.
import { useEffect, useState } from 'react'
import type { HauptStatus } from '../gemeinsam/nachrichten'
import { aufVerbindung, verbindungStarten, type Verbindung } from './kernverbindung'
import type { GeraeteInfo, HauptStatus } from '../gemeinsam/nachrichten'
import { aufVerbindung, auswerfen, verbindungStarten, type Verbindung } from './kernverbindung'
function istHauptStatus(wert: unknown): wert is HauptStatus {
return (
@@ -13,6 +13,18 @@ function istHauptStatus(wert: unknown): wert is HauptStatus {
)
}
function gb(bytes: number): string {
return `${(bytes / 1e9).toFixed(2).replace('.', ',')} GB`
}
const TYP_NAMEN: Record<GeraeteInfo['typ'], string> = {
cd: 'Audio-CD',
dvd: 'DVD',
bluray: 'Blu-ray',
uhd: '4K-UHD',
unknown: 'unbekannt',
}
interface KachelProps {
titel: string
zustand: 'ok' | 'fehler' | 'wartet'
@@ -39,12 +51,52 @@ function Kachel({ titel, zustand, zeilen }: KachelProps) {
)
}
function LaufwerkKachel({ geraet }: { geraet: GeraeteInfo }) {
const zustand = geraet.status === 'ready' ? 'ok' : geraet.status === 'empty' ? 'wartet' : 'fehler'
const punktFarbe =
zustand === 'ok' ? 'bg-emerald-400' : zustand === 'fehler' ? 'bg-red-400' : 'bg-slate-500'
return (
<div className="rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${punktFarbe}`} />
<h2 className="text-sm font-semibold tracking-wide text-slate-200">{geraet.name}</h2>
</div>
<button
onClick={() => auswerfen(geraet.id)}
disabled={geraet.status !== 'ready'}
className="rounded-lg border border-slate-600 px-3 py-1 text-xs text-slate-300 hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40"
title="Entriegeln, auswerfen, nachsehen — in dieser Reihenfolge"
>
Auswerfen
</button>
</div>
<div className="mt-2 space-y-0.5 font-mono text-xs text-slate-400">
<p>{geraet.modell.length > 0 ? geraet.modell : 'Modell unbekannt'}</p>
{geraet.status === 'ready' && (
<p className="text-slate-200">
{TYP_NAMEN[geraet.typ]}
{geraet.groesseBytes > 0 ? ` · ${gb(geraet.groesseBytes)}` : ''}
</p>
)}
{geraet.status === 'empty' && <p>keine Disc eingelegt</p>}
{geraet.status === 'unknown' && <p className="text-red-300">{geraet.grund}</p>}
{geraet.status !== 'unknown' && geraet.grund.length > 0 && (
<p className="text-amber-300">{geraet.grund}</p>
)}
</div>
</div>
)
}
export default function App() {
const [haupt, setHaupt] = useState<HauptStatus | null>(null)
const [verbindung, setVerbindung] = useState<Verbindung>({
verbunden: false,
kern: null,
pongLatenzMs: null,
laufwerke: [],
meldungen: [],
})
useEffect(() => {
@@ -63,52 +115,91 @@ export default function App() {
<div className="flex items-baseline gap-3">
<h1 className="text-2xl font-bold tracking-tight">Rippy</h1>
<span className="font-mono text-xs text-slate-400">
v{haupt?.version ?? '…'} · Etappe W-0 Gerüst
v{haupt?.version ?? '…'} · Etappe W-1 Laufwerk
</span>
</div>
<p className="mt-1 text-sm text-slate-400">
Drei Prozesse, eine Datenbank, eine Leine das Fundament für Rippy v5.
Drei Prozesse, eine Datenbank, eine Leine und die Disc-Wache läuft.
</p>
</header>
<main className="grid flex-1 content-start gap-4 p-6 sm:grid-cols-2">
<Kachel
titel="Kern (utilityProcess)"
zustand={verbindung.kern !== null ? 'ok' : haupt?.kern.laeuft ? 'wartet' : 'fehler'}
zeilen={
verbindung.kern !== null
? [
`PID ${verbindung.kern.pid} · Node ${verbindung.kern.nodeVersion}`,
`Neustarts: ${haupt?.kern.neustarts ?? 0}`,
]
: ['wartet auf Meldung …']
}
/>
<Kachel
titel="Datenbank (node:sqlite)"
zustand={db === null ? 'wartet' : db.ok ? 'ok' : 'fehler'}
zeilen={db === null ? ['wartet auf Kern …'] : db.ok ? [db.pfad] : [db.fehler]}
/>
<Kachel
titel="Prozess-Leine (Job Object)"
zustand={haupt === null ? 'wartet' : haupt.leine.ok ? 'ok' : 'fehler'}
zeilen={
haupt === null
? ['wartet auf Haupt …']
: haupt.leine.ok
? ['gesetzt — Kern und Werkzeuge sterben mit Rippy']
: [haupt.leine.fehler ?? 'nicht gesetzt']
}
/>
<Kachel
titel="Fenster ↔ Kern (MessagePort)"
zustand={verbindung.pongLatenzMs !== null ? 'ok' : 'wartet'}
zeilen={
verbindung.pongLatenzMs !== null
? [`Ping → Pong in ${verbindung.pongLatenzMs} ms`]
: ['Ping unterwegs …']
}
/>
<main className="flex-1 space-y-6 p-6">
<section>
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
Laufwerke
</h2>
{verbindung.laufwerke.length === 0 ? (
<p className="font-mono text-xs text-slate-500">
{verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'}
</p>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{verbindung.laufwerke.map((geraet) => (
<LaufwerkKachel key={geraet.id} geraet={geraet} />
))}
</div>
)}
</section>
<section>
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
Gerüst
</h2>
<div className="grid content-start gap-4 sm:grid-cols-2">
<Kachel
titel="Kern (utilityProcess)"
zustand={verbindung.kern !== null ? 'ok' : haupt?.kern.laeuft ? 'wartet' : 'fehler'}
zeilen={
verbindung.kern !== null
? [
`PID ${verbindung.kern.pid} · Node ${verbindung.kern.nodeVersion}`,
`Neustarts: ${haupt?.kern.neustarts ?? 0}`,
]
: ['wartet auf Meldung …']
}
/>
<Kachel
titel="Datenbank (node:sqlite)"
zustand={db === null ? 'wartet' : db.ok ? 'ok' : 'fehler'}
zeilen={db === null ? ['wartet auf Kern …'] : db.ok ? [db.pfad] : [db.fehler]}
/>
<Kachel
titel="Prozess-Leine (Job Object)"
zustand={haupt === null ? 'wartet' : haupt.leine.ok ? 'ok' : 'fehler'}
zeilen={
haupt === null
? ['wartet auf Haupt …']
: haupt.leine.ok
? ['gesetzt — Kern und Werkzeuge sterben mit Rippy']
: [haupt.leine.fehler ?? 'nicht gesetzt']
}
/>
<Kachel
titel="Fenster ↔ Kern (MessagePort)"
zustand={verbindung.pongLatenzMs !== null ? 'ok' : 'wartet'}
zeilen={
verbindung.pongLatenzMs !== null
? [`Ping → Pong in ${verbindung.pongLatenzMs} ms`]
: ['Ping unterwegs …']
}
/>
</div>
</section>
{verbindung.meldungen.length > 0 && (
<section>
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
Ereignisse
</h2>
<div className="space-y-1 rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
{verbindung.meldungen.map((zeile, i) => (
<p key={i} className="font-mono text-xs text-slate-400">
{zeile}
</p>
))}
</div>
</section>
)}
</main>
<footer className="border-t border-slate-700/60 px-6 py-3 text-center text-xs text-slate-500">
+66 -16
View File
@@ -2,43 +2,84 @@
// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung;
// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren.
//
// R2 gilt auch hier: Der letzte bekannte Kern-Stand bleibt stehen und wird
// R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
// zurückgesetzt, nur weil gerade keine Nachricht kam.
import { istKernNachricht, type KernNachricht } from '../gemeinsam/nachrichten'
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
import { istKernNachricht, type GeraeteInfo, type KernNachricht } from '../gemeinsam/nachrichten'
export interface Verbindung {
verbunden: boolean
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
pongLatenzMs: number | null
laufwerke: GeraeteInfo[]
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[]
}
type Horcher = (stand: Verbindung) => void
const stand: Verbindung = { verbunden: false, kern: null, pongLatenzMs: null }
const MAX_MELDUNGEN = 8
const stand: Verbindung = { verbunden: false, kern: null, pongLatenzMs: null, laufwerke: [], meldungen: [] }
const horcher = new Set<Horcher>()
let gestartet = false
let port: MessagePort | null = null
function melden(): void {
for (const h of horcher) h({ ...stand })
for (const h of horcher) h({ ...stand, laufwerke: [...stand.laufwerke], meldungen: [...stand.meldungen] })
}
function portAnnehmen(port: MessagePort): void {
stand.verbunden = true
port.onmessage = (ereignis: MessageEvent) => {
const nachricht: unknown = ereignis.data
if (!istKernNachricht(nachricht)) return
if (nachricht.art === 'kern-bereit') {
function meldung(text: string): void {
stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN)
}
function kernNachricht(nachricht: KernNachricht): void {
switch (nachricht.art) {
case 'kern-bereit':
stand.kern = nachricht
} else if (nachricht.art === 'pong') {
break
case 'pong':
stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit)
// Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück.
window.rippy.melden('kern-pong')
break
case 'kern-fehler':
meldung(`${nachricht.text}`)
break
case 'laufwerke':
stand.laufwerke = nachricht.liste
for (const ereignis of nachricht.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
meldung(`Disc erkannt in ${ereignis.geraet.name}${ereignis.geraet.typ}`)
} else if (ereignis.art === 'disc-entnommen') {
meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
} else if (ereignis.art === 'laufwerk-weg') {
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
}
}
break
case 'auswurf-ergebnis': {
const text =
nachricht.grund === 'ok'
? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)`
: `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? `${nachricht.fehler}` : ''}`
meldung(nachricht.grund === 'ok' ? text : `${text}`)
break
}
melden()
}
port.start()
port.postMessage({ art: 'ping', zeit: Date.now() })
melden()
}
function portAnnehmen(neuerPort: MessagePort): void {
port = neuerPort
stand.verbunden = true
neuerPort.onmessage = (ereignis: MessageEvent) => {
const nachricht: unknown = ereignis.data
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
}
neuerPort.start()
neuerPort.postMessage({ art: 'ping', zeit: Date.now() })
melden()
}
@@ -63,6 +104,15 @@ export function verbindungStarten(): void {
export function aufVerbindung(h: Horcher): () => void {
horcher.add(h)
h({ ...stand })
h({ ...stand, laufwerke: [...stand.laufwerke], meldungen: [...stand.meldungen] })
return () => horcher.delete(h)
}
export function auswerfen(id: string): void {
if (port === null) {
meldung(`⚠ Auswerfen ${id}: keine Verbindung zum Kern`)
melden()
return
}
port.postMessage({ art: 'auswerfen', id })
}
+42 -2
View File
@@ -11,14 +11,43 @@
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
// ── Laufwerke (Etappe W-1) ──────────────────────────────────────────────
export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
export type LaufwerkStatus = 'ready' | 'empty' | 'unknown'
export interface GeraeteInfo {
/** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */
id: string
name: string
typ: DiscTyp
pfad: string
status: LaufwerkStatus
modell: string
seriennummer: string
/** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */
groesseBytes: number
/** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */
grund: string
}
export type WacheEreignis =
| { art: 'disc-eingelegt'; geraet: GeraeteInfo }
| { art: 'disc-entnommen'; geraet: GeraeteInfo }
| { art: 'laufwerk-neu'; geraet: GeraeteInfo }
| { art: 'laufwerk-weg'; id: string }
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
/** Nachrichten, die der Kern verschickt. */
export type KernNachricht =
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
| { art: 'pong'; zeit: number }
| { art: 'kern-fehler'; text: string }
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
export type FensterNachricht = { art: 'ping'; zeit: number }
export type FensterNachricht = { art: 'ping'; zeit: number } | { art: 'auswerfen'; id: string }
/** Nachrichten, die das Haupt an den Kern schickt (Port-Übergabe). */
export type HauptNachricht = { art: 'fenster-port' }
@@ -48,6 +77,14 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
return typeof wert.zeit === 'number'
case 'kern-fehler':
return typeof wert.text === 'string'
case 'laufwerke':
return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
case 'auswurf-ergebnis':
return (
typeof wert.id === 'string' &&
typeof wert.grund === 'string' &&
(wert.fehler === null || typeof wert.fehler === 'string')
)
default:
return false
}
@@ -61,7 +98,10 @@ export function istDbStatus(wert: unknown): wert is DbStatus {
}
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
return istObjekt(wert) && wert.art === 'ping' && typeof wert.zeit === 'number'
if (!istObjekt(wert)) return false
if (wert.art === 'ping') return typeof wert.zeit === 'number'
if (wert.art === 'auswerfen') return typeof wert.id === 'string' && wert.id.length > 0
return false
}
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
+3 -6
View File
@@ -39,7 +39,7 @@ function start(): void {
}
let fenster: BrowserWindow | null = null
let letzteKernNachricht: KernNachricht | null = null
let kernBereit: Extract<KernNachricht, { art: 'kern-bereit' }> | null = null
const smoke = {
fenster: false,
@@ -52,8 +52,8 @@ function start(): void {
const kernVerwaltung = new KernVerwaltung(
join(app.getPath('userData'), 'rippy.db'),
(nachricht) => {
letzteKernNachricht = nachricht
if (nachricht.art === 'kern-bereit') {
kernBereit = nachricht
smoke.kern = true
smoke.datenbank = nachricht.datenbank.ok
if (!nachricht.datenbank.ok) {
@@ -142,11 +142,8 @@ function start(): void {
console.error(`SMOKE: Bild fehlgeschlagen: ${String(fehler)}`)
}
}
const kern = letzteKernNachricht
const kernInfo =
kern !== null && kern.art === 'kern-bereit'
? `kern=pid:${kern.pid},node:${kern.nodeVersion}`
: 'kern=?'
kernBereit !== null ? `kern=pid:${kernBereit.pid},node:${kernBereit.nodeVersion}` : 'kern=?'
console.log(
`SMOKE: OK — fenster=geladen ${kernInfo} datenbank=ok leine=gesetzt pong=ok version=${app.getVersion()}`,
)
+76 -4
View File
@@ -1,7 +1,7 @@
// Der Kern — läuft als Electron utilityProcess (KONZEPT-WINDOWS.md § 4.1).
// Hier passiert die ganze Arbeit; das Fenster zeigt nur an. In W-0 kann der
// Kern: die Datenbank öffnen (node:sqlite, § 3.4), sich beim Haupt melden und
// über einen direkten MessagePort mit dem Fenster reden (Ping → Pong).
// Hier passiert die ganze Arbeit; das Fenster zeigt nur an. Stand W-1:
// Datenbank (node:sqlite, § 3.4), Disc-Wache im 3-Sekunden-Takt (§ 5),
// Auswerfen mit Nachsehen (R3), Ping/Pong über den direkten MessagePort.
//
// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess«
// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen
@@ -13,6 +13,9 @@ import {
type DbStatus,
type KernNachricht,
} from '../gemeinsam/nachrichten'
import type { LaufwerkApi } from './laufwerk/codes'
import { auswerfenMitGrund, geraeteInfo, kennung, listOptischeLaufwerke } from './laufwerk/disc'
import { DiscWache } from './laufwerk/wache'
import { Datenbank } from './speicher/db'
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
@@ -57,6 +60,66 @@ function anFenster(nachricht: KernNachricht): void {
fensterPort?.postMessage(nachricht)
}
function anHauptUndFenster(nachricht: KernNachricht): void {
process.parentPort.postMessage(nachricht)
anFenster(nachricht)
}
// ── Laufwerke (W-1) ─────────────────────────────────────────────────────
// koffi wird erst HIER geladen, über einen echten dynamischen import():
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
// wörtliches require überlebte das Bündeln nicht — im Smoke-Lauf am
// 30.08.2026 sichtbar geworden). Scheitert das Laden (falsche Plattform,
// kaputtes Paket), läuft der Kern weiter und der Grund steht im Fenster (R4).
let laufwerkApi: LaufwerkApi | null = null
let laufwerkFehler: string | null = null
let wache: DiscWache | null = null
const laufwerkeBereit: Promise<void> = (async () => {
try {
if (process.platform !== 'win32') {
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
}
const { win32Api } = await import('./laufwerk/win32')
laufwerkApi = win32Api()
wache = new DiscWache(
() => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)),
(meldung) => {
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
},
(fehler) => {
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
console.error(`[kern] Disc-Wache: Runde fehlgeschlagen: ${String(fehler)}`)
anFenster({ art: 'kern-fehler', text: `Disc-Wache: ${String(fehler)}` })
},
)
wache.starten()
} catch (fehler) {
laufwerkFehler = String(fehler)
console.error(`[kern] Laufwerke: ${laufwerkFehler}`)
}
})()
async function auswerfen(id: string): Promise<void> {
await laufwerkeBereit
if (laufwerkApi === null) {
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: laufwerkFehler })
return
}
const geraete = listOptischeLaufwerke(laufwerkApi)
const geraet = geraete.find((g) => kennung(g) === id)
if (geraet === undefined) {
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` })
return
}
const { grund, fehler } = await auswerfenMitGrund(laufwerkApi, geraet)
anFenster({ art: 'auswurf-ergebnis', id, grund, fehler })
// Die Wache merkt den neuen Zustand spätestens in der nächsten Runde;
// eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch.
wache?.runde()
}
process.parentPort.on('message', (ereignis) => {
const nachricht: unknown = ereignis.data
if (istHauptNachricht(nachricht) && nachricht.art === 'fenster-port') {
@@ -67,13 +130,22 @@ process.parentPort.on('message', (ereignis) => {
if (fensterPort === null) return
fensterPort.on('message', (portEreignis) => {
const frage: unknown = portEreignis.data
if (istFensterNachricht(frage) && frage.art === 'ping') {
if (!istFensterNachricht(frage)) return
if (frage.art === 'ping') {
anFenster({ art: 'pong', zeit: frage.zeit })
} else if (frage.art === 'auswerfen') {
void auswerfen(frage.id)
}
})
fensterPort.start()
// Damit das Fenster nach dem Verbinden sofort den Stand kennt:
anFenster(bereitNachricht())
void laufwerkeBereit.then(() => {
if (laufwerkFehler !== null) {
anFenster({ art: 'kern-fehler', text: `Laufwerke: ${laufwerkFehler}` })
}
anFenster({ art: 'laufwerke', liste: wache?.aktuellerStand() ?? [], ereignisse: [] })
})
}
})
+94
View File
@@ -0,0 +1,94 @@
// Win32-Steuercodes und -Konstanten für optische Laufwerke — HERGELEITET,
// nicht abgeschrieben (Regel D). Bewusst OHNE koffi: disc.ts und die Tests
// brauchen die Zahlen, aber kein natives Modul. Die echten Aufrufe stehen
// allein in win32.ts (Wächter R1).
//
// Ein Zahlendreher liefert kein „unbekannter Befehl", sondern
// ERROR_INVALID_FUNCTION — das sieht aus wie „das Laufwerk kann das nicht";
// test/laufwerk.test.ts rechnet die Werte deshalb gegen die in der
// Microsoft-Dokumentation genannten Zahlen gegen.
//
// Fundstellen: CTL_CODE/FILE_DEVICE_*/METHOD_* — winioctl.h;
// IOCTL_STORAGE_* — learn.microsoft.com/windows/win32/api/winioctl;
// IOCTL_CDROM_* — ntddcdrm.h. Gemessen in beweise/laufwerk.js (30.08.2026),
// Herleitung identisch mit src/rippy/drives/win_ioctl.py (Docker-Zweig,
// dort an echter Hardware bewiesen).
// ── CTL_CODE aus winioctl.h, eins zu eins ───────────────────────────────
export function ctlCode(geraetetyp: number, funktion: number, methode: number, zugriff: number): number {
return ((geraetetyp << 16) | (zugriff << 14) | (funktion << 2) | methode) >>> 0
}
const FILE_DEVICE_CD_ROM = 0x02
const FILE_DEVICE_DISK = 0x07
const FILE_DEVICE_MASS_STORAGE = 0x2d
const METHOD_BUFFERED = 0
const METHOD_OUT_DIRECT = 2
const FILE_ANY_ACCESS = 0
const FILE_READ_ACCESS = 0x0001
export const IOCTL_STORAGE_CHECK_VERIFY2 = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0200, METHOD_BUFFERED, FILE_ANY_ACCESS)
export const IOCTL_STORAGE_MEDIA_REMOVAL = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0201, METHOD_BUFFERED, FILE_READ_ACCESS)
export const IOCTL_STORAGE_EJECT_MEDIA = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0202, METHOD_BUFFERED, FILE_READ_ACCESS)
// LOAD_MEDIA (Schublade einziehen) fehlt MIT ABSICHT: Am BU40N des
// Commanders antwortet es mit ERROR_INVALID_FUNCTION — bei flachen und
// externen Laufwerken die Regel. Ein Knopf, der auf der Hälfte aller
// Laufwerke still nichts tut, wäre ein Placebo (windows.py, 28.08.2026).
export const IOCTL_STORAGE_QUERY_PROPERTY = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0500, METHOD_BUFFERED, FILE_ANY_ACCESS)
export const IOCTL_CDROM_DISK_TYPE = ctlCode(FILE_DEVICE_CD_ROM, 0x0010, METHOD_BUFFERED, FILE_ANY_ACCESS)
export const IOCTL_DISK_GET_LENGTH_INFO = ctlCode(FILE_DEVICE_DISK, 0x0017, METHOD_BUFFERED, FILE_READ_ACCESS)
// Audio-CD (W-7): TOC + rohe Sektoren. Herleitung wie win_ioctl.py.
export const IOCTL_CDROM_READ_TOC = ctlCode(FILE_DEVICE_CD_ROM, 0x0000, METHOD_BUFFERED, FILE_READ_ACCESS)
export const IOCTL_CDROM_RAW_READ = ctlCode(FILE_DEVICE_CD_ROM, 0x000f, METHOD_OUT_DIRECT, FILE_READ_ACCESS)
export const CDROM_TOC_BYTES = 4 + 100 * 8
// STORAGE_DEVICE_DESCRIPTOR: Offsets der Zeichenketten-Verweise (winioctl.h).
export const SDD_VENDOR_ID_OFFSET = 12
export const SDD_PRODUCT_ID_OFFSET = 16
export const SDD_PRODUCT_REVISION_OFFSET = 20
export const SDD_SERIAL_NUMBER_OFFSET = 24
export const CDROM_DISK_AUDIO_TRACK = 0x01
export const CDROM_DISK_DATA_TRACK = 0x02
// GetLastError-Werte, an denen Zugriffe scheitern (winerror.h).
export const ERROR_INVALID_FUNCTION = 1
export const ERROR_FILE_NOT_FOUND = 2
export const ERROR_ACCESS_DENIED = 5
export const ERROR_NOT_READY = 21
export const ERROR_NOT_SUPPORTED = 50
export const ERROR_NO_MEDIA_IN_DRIVE = 1112
export const DRIVE_CDROM = 5
export const GENERIC_READ = 0x8000_0000
export const FILE_SHARE_READ = 0x01
export const FILE_SHARE_WRITE = 0x02
export const OPEN_EXISTING = 3
/** Ein fehlgeschlagener Win32-Aufruf, mit dem Code von GetLastError(). */
export class Win32Fehler extends Error {
constructor(
was: string,
readonly code: number,
) {
super(`${was} scheiterte (Win32-Fehler ${code})`)
this.name = 'Win32Fehler'
}
}
/** Die Schnittstelle, gegen die disc.ts arbeitet — in Tests nachgebildet. */
export interface LaufwerkApi {
laufwerksbuchstaben(): string[]
laufwerkstyp(buchstabe: string): number
/** Öffnet das Gerät. `zugriff` 0 fragt das GERÄT, GENERIC_READ das MEDIUM —
* bei gestörter Disc scheitert das zweite und das erste geht weiter
* (rc11-Befund, in beweise/laufwerk.js nachgemessen). Wirft Win32Fehler. */
oeffnen(geraetepfad: string, zugriff: number): unknown
schliessen(handle: unknown): void
/** DeviceIoControl. Wirft Win32Fehler, sonst die Antwort (ggf. leer). */
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes?: number): Buffer
}
+304
View File
@@ -0,0 +1,304 @@
// Laufwerks-Logik: Typ, Zustand, Auswerfen — KONZEPT § 4.2/§ 5.
// Alles hier arbeitet gegen die LaufwerkApi-Schnittstelle (codes.ts) und
// ist damit ohne Windows und ohne Laufwerk prüfbar; die echten Aufrufe
// liegen allein in win32.ts. Die Regeln stammen aus dem Docker-Treiber
// (windows.py, an echter Hardware gemessen 28.08.2026):
//
// 1. Auswerfen heißt: ENTRIEGELN, AUSWERFEN, NACHSEHEN. MakeMKV verriegelt
// die Tür während des Rips; ein verriegeltes Laufwerk quittiert den
// Auswurf mit Erfolg und tut nichts (R3).
// 2. Ein unbekannter Zustand heißt UNBEKANNT, nicht leer. „Konnte nicht
// nachsehen" und „keine Disc" zu vermischen leerte in v1 die Listen (R2).
import {
CDROM_DISK_AUDIO_TRACK,
CDROM_DISK_DATA_TRACK,
ERROR_ACCESS_DENIED,
ERROR_FILE_NOT_FOUND,
ERROR_INVALID_FUNCTION,
ERROR_NOT_READY,
ERROR_NOT_SUPPORTED,
ERROR_NO_MEDIA_IN_DRIVE,
DRIVE_CDROM,
GENERIC_READ,
IOCTL_CDROM_DISK_TYPE,
IOCTL_DISK_GET_LENGTH_INFO,
IOCTL_STORAGE_CHECK_VERIFY2,
IOCTL_STORAGE_EJECT_MEDIA,
IOCTL_STORAGE_MEDIA_REMOVAL,
IOCTL_STORAGE_QUERY_PROPERTY,
SDD_PRODUCT_ID_OFFSET,
SDD_PRODUCT_REVISION_OFFSET,
SDD_SERIAL_NUMBER_OFFSET,
SDD_VENDOR_ID_OFFSET,
Win32Fehler,
type LaufwerkApi,
} from './codes'
import type { AuswurfGrund, DiscTyp, GeraeteInfo, LaufwerkStatus } from '../../gemeinsam/nachrichten'
export type { DiscTyp, GeraeteInfo, LaufwerkStatus }
// ── Schwellen für die Typ-Zuordnung (aus cdrom.py, dort mit Begründung) ──
// DVD9 ~8,5 GB; Blu-ray beginnt bei 25 GB → ab 10 GiB sicher Blu-ray.
export const BLURAY_MIN_BYTES = 10 * 1024 ** 3
// UHD sind BD-66/BD-100; eine BD-50 bleibt unter ~47 GiB → ab 55 GiB UHD.
// (Am 28.08.2026 gegengeprüft: BD-50 mit 44,84 GiB wird „bluray".)
export const UHD_MIN_BYTES = 55 * 1024 ** 3
/** Pure Zuordnung: Spur-Flags + Größe → Disc-Typ. */
export function classify(spurFlags: number, groesseBytes: number): DiscTyp {
if (spurFlags & CDROM_DISK_AUDIO_TRACK) return 'cd'
if (spurFlags & CDROM_DISK_DATA_TRACK) {
if (groesseBytes >= UHD_MIN_BYTES) return 'uhd'
return groesseBytes >= BLURAY_MIN_BYTES ? 'bluray' : 'dvd'
}
return 'unknown'
}
/** „D:" oder „D" oder „D:\" → \\.\D: — der Gerätepfad hat KEINEN
* abschließenden Backslash. Mit einem öffnet CreateFileW das
* Wurzelverzeichnis statt des Geräts, und die Steuercodes scheitern mit
* ERROR_INVALID_FUNCTION — das sieht aus wie „kann das Laufwerk nicht". */
export function geraetepfad(buchstabe: string): string {
const kern = (buchstabe ?? '').trim().replace(/[\\/]+$/, '').replace(/:$/, '')
if (kern.length === 0) throw new Error('Kein Laufwerksbuchstabe angegeben')
return '\\\\.\\' + kern[0].toUpperCase() + ':'
}
/** `\\.\G:` → `G`. Hin- und Rückweg gehören zusammen (der /dev/G-404-Fund). */
export function kennung(geraet: string): string {
return geraet.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
}
export function listOptischeLaufwerke(api: LaufwerkApi): string[] {
return api
.laufwerksbuchstaben()
.filter((b) => api.laufwerkstyp(b) === DRIVE_CDROM)
.map(geraetepfad)
.sort()
}
// ── Zustand ─────────────────────────────────────────────────────────────
function mediumDa(api: LaufwerkApi, handle: unknown): boolean {
try {
api.steuern(handle, IOCTL_STORAGE_CHECK_VERIFY2)
return true
} catch (fehler) {
if (fehler instanceof Win32Fehler && (fehler.code === ERROR_NOT_READY || fehler.code === ERROR_NO_MEDIA_IN_DRIVE)) {
return false
}
throw fehler
}
}
function mitHandle<T>(api: LaufwerkApi, geraet: string, zugriff: number, tun: (handle: unknown) => T): T {
const handle = api.oeffnen(geraet, zugriff)
try {
return tun(handle)
} finally {
api.schliessen(handle)
}
}
/** Spur-Flags (Audio/Daten) — 0 heißt unbekannt, classify sagt dann 'unknown'. */
export function spurFlags(api: LaufwerkApi, geraet: string): number {
return mitHandle(api, geraet, GENERIC_READ, (h) => {
const antwort = api.steuern(h, IOCTL_CDROM_DISK_TYPE, null, 4)
return antwort.length >= 4 ? antwort.readUInt32LE(0) : 0
})
}
export function discGroesseBytes(api: LaufwerkApi, geraet: string): number {
return mitHandle(api, geraet, GENERIC_READ, (h) => {
const antwort = api.steuern(h, IOCTL_DISK_GET_LENGTH_INFO, null, 8)
return antwort.length >= 8 ? Number(antwort.readBigUInt64LE(0)) : 0
})
}
// ── Verriegeln ──────────────────────────────────────────────────────────
/** Tür verriegeln (true) / entriegeln (false). PREVENT_MEDIA_REMOVAL ist
* eine Struktur mit genau einem BOOLEAN. Wirft bei Fehlschlag. */
export function verriegeln(api: LaufwerkApi, geraet: string, an: boolean): void {
mitHandle(api, geraet, GENERIC_READ, (h) => {
api.steuern(h, IOCTL_STORAGE_MEDIA_REMOVAL, Buffer.from([an ? 1 : 0]))
})
}
// ── Auswerfen ───────────────────────────────────────────────────────────
export type { AuswurfGrund }
const AUSWURF_NACHSEHEN_RUNDEN = 5
/** Entriegeln → auswerfen → NACHSEHEN (R3). Wirft NIE; gibt den Grund
* zurück. `warten` ist injizierbar, damit der Ablauf ohne echte Sekunden
* prüfbar ist. */
export async function auswerfenMitGrund(
api: LaufwerkApi,
geraet: string,
warten: (ms: number) => Promise<void> = (ms) => new Promise((f) => setTimeout(f, ms)),
): Promise<{ grund: AuswurfGrund; fehler: string | null }> {
let handle: unknown
try {
handle = api.oeffnen(geraet, GENERIC_READ)
} catch (fehler) {
return { grund: 'kein-zugriff', fehler: String(fehler) }
}
try {
// Entriegeln zuerst. Scheitert es, wird der Auswurf trotzdem versucht —
// bei einem nicht verriegelten Laufwerk klappt er ohnehin. Der Fehlschlag
// wird aber MITGEFÜHRT, nicht verschluckt (R4).
let entriegelnFehler: string | null = null
try {
api.steuern(handle, IOCTL_STORAGE_MEDIA_REMOVAL, Buffer.from([0]))
} catch (fehler) {
entriegelnFehler = String(fehler)
}
try {
api.steuern(handle, IOCTL_STORAGE_EJECT_MEDIA)
} catch (fehler) {
return { grund: 'abgelehnt', fehler: String(fehler) }
}
// NACHSEHEN — der Befehl oben meldet nur, dass er angenommen wurde.
for (let runde = 0; runde < AUSWURF_NACHSEHEN_RUNDEN; runde++) {
try {
if (!mediumDa(api, handle)) return { grund: 'ok', fehler: null }
} catch (fehler) {
return { grund: 'abgelehnt', fehler: String(fehler) }
}
await warten(1000)
}
return { grund: 'bleibt-drin', fehler: entriegelnFehler }
} finally {
try {
api.schliessen(handle)
} catch {
// Ein Handle, das sich nicht schließen lässt, ändert am Ergebnis
// nichts mehr — der Auswurf-Grund steht fest und wird gemeldet.
}
}
}
// ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die
// Ursache und behielt sie für sich — nie wieder) ────────────────────────
const ZUGRIFFS_GRUENDE: Record<number, string> = {
[ERROR_INVALID_FUNCTION]:
'Das Laufwerk beantwortet keine Medien-Abfragen mehr. Das passiert nach abgebrochenen Lesevorgängen. Abhilfe: Disc über die Taste am Laufwerk auswerfen und neu einlegen; hilft das nicht, den Rechner neu starten.',
[ERROR_NOT_SUPPORTED]: 'Das Laufwerk lehnt die Abfrage gerade ab. Abhilfe: Disc auswerfen und neu einlegen.',
[ERROR_ACCESS_DENIED]: 'Ein anderes Programm hält das Laufwerk fest.',
[ERROR_FILE_NOT_FOUND]: 'Dieses Laufwerk gibt es nicht mehr.',
[ERROR_NOT_READY]: 'Es liegt keine Disc im Laufwerk.',
}
export function zugriffsGrund(fehler: unknown): string {
const code = fehler instanceof Win32Fehler ? fehler.code : null
if (code !== null && code in ZUGRIFFS_GRUENDE) return ZUGRIFFS_GRUENDE[code]
if (code !== null) {
return `Das Laufwerk antwortet nicht (Win32-Fehler ${code}). Abhilfe: Disc auswerfen und neu einlegen.`
}
return 'Das Laufwerk antwortet nicht.'
}
// ── Geräteangaben ───────────────────────────────────────────────────────
/** Eine nullterminierte Zeichenkette, auf die ein Offset im Puffer zeigt.
* Offset 0 heißt „gibt es nicht" (dokumentiertes Verhalten des
* STORAGE_DEVICE_DESCRIPTOR). Reine Funktion, ohne Laufwerk prüfbar. */
export function textBei(puffer: Buffer, offsetPos: number): string {
if (puffer.length < offsetPos + 4) return ''
const offset = puffer.readUInt32LE(offsetPos)
if (offset === 0 || offset >= puffer.length) return ''
let ende = puffer.indexOf(0, offset)
if (ende < 0) ende = puffer.length
return puffer.toString('latin1', offset, ende).trim()
}
export interface Geraeteangaben {
hersteller: string
modell: string
seriennummer: string
}
/** Hersteller/Modell/Seriennummer des LAUFWERKS. Wirft nicht — ein Laufwerk
* ohne Angaben ist ärgerlich, aber kein Grund, die Liste scheitern zu
* lassen. Öffnet mit Zugriff 0: Die GERÄTE-Auskunft geht auch dann noch,
* wenn das Medium gestört ist (rc11-Befund). */
export function geraeteangaben(api: LaufwerkApi, geraet: string): Geraeteangaben {
const leer: Geraeteangaben = { hersteller: '', modell: '', seriennummer: '' }
const abfrage = Buffer.alloc(12) // PropertyId=0, QueryType=0, Reserved
try {
return mitHandle(api, geraet, 0, (h) => {
const puffer = api.steuern(h, IOCTL_STORAGE_QUERY_PROPERTY, abfrage, 1024)
return {
hersteller: textBei(puffer, SDD_VENDOR_ID_OFFSET),
modell: [textBei(puffer, SDD_PRODUCT_ID_OFFSET), textBei(puffer, SDD_PRODUCT_REVISION_OFFSET)]
.filter((t) => t.length > 0)
.join(' '),
seriennummer: textBei(puffer, SDD_SERIAL_NUMBER_OFFSET),
}
})
} catch {
return leer
}
}
// Die Angaben eines Laufwerks ändern sich nicht, solange es dasselbe
// Laufwerk ist — die Wache fragt aber alle drei Sekunden. Ohne Gedächtnis
// wäre das alle drei Sekunden ein CreateFileW auf ein Gerät, das gerade
// rippt (Muster aus windows.py).
const angabenSpeicher = new Map<string, Geraeteangaben>()
function angabenGemerkt(api: LaufwerkApi, geraet: string): Geraeteangaben {
let angaben = angabenSpeicher.get(geraet)
if (angaben === undefined) {
angaben = geraeteangaben(api, geraet)
angabenSpeicher.set(geraet, angaben)
}
return angaben
}
/** Nur für Tests: das Angaben-Gedächtnis leeren. */
export function angabenSpeicherLeeren(): void {
angabenSpeicher.clear()
}
// ── Der Geräte-Eintrag fürs UI ──────────────────────────────────────────
export function geraeteInfo(api: LaufwerkApi, geraet: string): GeraeteInfo {
let status: LaufwerkStatus = 'unknown'
let typ: DiscTyp = 'unknown'
let groesse = 0
let grund = ''
try {
// Medium-Frage über Zugriff 0 — geht auch bei gestörter Disc (rc11).
const da = mitHandle(api, geraet, 0, (h) => mediumDa(api, h))
if (da) {
status = 'ready'
try {
groesse = discGroesseBytes(api, geraet)
typ = classify(spurFlags(api, geraet), groesse)
} catch (fehler) {
typ = 'unknown'
grund = zugriffsGrund(fehler)
}
} else {
status = 'empty'
}
} catch (fehler) {
status = 'unknown'
grund = zugriffsGrund(fehler)
}
const buchstabe = kennung(geraet)
const angaben = angabenGemerkt(api, geraet)
return {
id: buchstabe,
name: `Laufwerk ${buchstabe}:`,
typ,
pfad: geraet,
status,
modell: [angaben.hersteller, angaben.modell].filter((t) => t.length > 0).join(' '),
seriennummer: angaben.seriennummer,
groesseBytes: groesse,
grund,
}
}
+107
View File
@@ -0,0 +1,107 @@
// Die Disc-Wache — KONZEPT § 5, Schritt 1: ein 3-Sekunden-Takt über
// IOCTL_STORAGE_CHECK_VERIFY2 (praktisch kostenlos, in beweise/laufwerk.js
// gemessen). WM_DEVICECHANGE ist die spätere Verfeinerung; wenn sie kommt,
// bleibt dieser Takt als Rückfallebene bestehen.
//
// Regeln, die hier verdrahtet sind:
// R2 — Ein Fehlschlag löscht nie einen Zustand: Scheitert eine Runde,
// bleibt der letzte bekannte Stand stehen; gemeldet wird der Fehler.
// R4 — Jede Hintergrundschleife meldet ihren Fehler. Kein stilles catch.
import type { GeraeteInfo, WacheEreignis } from '../../gemeinsam/nachrichten'
export type { WacheEreignis }
/** Pure Funktion: Zustandsvergleich → Ereignisse (Muster aus
* bus/waechter.py `laufwerks_unterschiede`). */
export function laufwerksUnterschiede(
vorher: ReadonlyMap<string, GeraeteInfo>,
jetzt: ReadonlyMap<string, GeraeteInfo>,
): WacheEreignis[] {
const ereignisse: WacheEreignis[] = []
for (const [id, stand] of jetzt) {
const alt = vorher.get(id)
if (alt === undefined) {
ereignisse.push({ art: 'laufwerk-neu', geraet: stand })
if (stand.status === 'ready') ereignisse.push({ art: 'disc-eingelegt', geraet: stand })
continue
}
const vorherBereit = alt.status === 'ready'
const jetztBereit = stand.status === 'ready'
if (!vorherBereit && jetztBereit) {
ereignisse.push({ art: 'disc-eingelegt', geraet: stand })
} else if (vorherBereit && !jetztBereit && stand.status === 'empty') {
// NUR der Übergang ready → empty ist eine Entnahme. ready → unknown
// heißt „konnte nicht nachsehen" — das ist KEINE Aussage über die
// Disc (R2); der Grund steht im Geräte-Eintrag.
ereignisse.push({ art: 'disc-entnommen', geraet: stand })
}
}
for (const id of vorher.keys()) {
if (!jetzt.has(id)) ereignisse.push({ art: 'laufwerk-weg', id })
}
return ereignisse
}
export const WACHE_TAKT_MS = 3000
export interface WacheMeldung {
laufwerke: GeraeteInfo[]
ereignisse: WacheEreignis[]
}
export class DiscWache {
private stand = new Map<string, GeraeteInfo>()
private zeiger: NodeJS.Timeout | null = null
private laeuft = false
constructor(
/** Liest den aktuellen Stand aller Laufwerke. Darf werfen. */
private readonly lesen: () => GeraeteInfo[],
/** Bekommt jede Runde mit Änderungen — und jede mit Ereignissen. */
private readonly melden: (meldung: WacheMeldung) => void,
/** R4: Ein Rundenfehler wird GEMELDET, nicht verschluckt. */
private readonly fehlerMelden: (fehler: unknown) => void,
private readonly taktMs: number = WACHE_TAKT_MS,
) {}
starten(): void {
if (this.laeuft) return
this.laeuft = true
this.runde()
}
stoppen(): void {
this.laeuft = false
if (this.zeiger !== null) {
clearTimeout(this.zeiger)
this.zeiger = null
}
}
/** Eine Runde: lesen, vergleichen, melden, nächste Runde planen.
* Öffentlich, damit Tests sie ohne Uhr treiben können. */
runde(): void {
if (!this.laeuft) return
try {
const liste = this.lesen()
const jetzt = new Map(liste.map((g) => [g.id, g]))
const ereignisse = laufwerksUnterschiede(this.stand, jetzt)
const geaendert =
ereignisse.length > 0 || JSON.stringify([...jetzt.values()]) !== JSON.stringify([...this.stand.values()])
this.stand = jetzt
if (geaendert) this.melden({ laufwerke: liste, ereignisse })
} catch (fehler) {
// R2: Der letzte Stand bleibt stehen. R4: Der Fehler wird laut.
this.fehlerMelden(fehler)
}
if (this.laeuft) {
this.zeiger = setTimeout(() => this.runde(), this.taktMs)
// Der Takt darf einen Programm-Exit nicht aufhalten.
this.zeiger.unref?.()
}
}
aktuellerStand(): GeraeteInfo[] {
return [...this.stand.values()]
}
}
+88
View File
@@ -0,0 +1,88 @@
// DER EINZIGE ORT MIT koffi im Kern (Regel R1, KONZEPT § 4.3) — die rohe
// Win32-Anbindung für optische Laufwerke. Entscheidungen stehen NICHT hier,
// sondern in disc.ts; die Konstanten samt Herleitung in codes.ts. Dadurch
// ist alles außer diesen Aufrufen ohne Windows und ohne Laufwerk prüfbar
// (dasselbe Muster wie win_ioctl.py/windows.py im Docker-Zweig, dort an
// echter Hardware bewiesen — 28.08.2026, BU40N; hier gemessen in
// beweise/laufwerk.js, 30.08.2026).
import koffi from 'koffi'
import { FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, Win32Fehler, type LaufwerkApi } from './codes'
export type { LaufwerkApi }
let echteApi: LaufwerkApi | null = null
/** Die echten kernel32-Aufrufe — einmal geladen, dann wiederverwendet. */
export function win32Api(): LaufwerkApi {
if (echteApi !== null) return echteApi
const kernel32 = koffi.load('kernel32.dll')
const GetLogicalDrives = kernel32.func('uint32_t __stdcall GetLogicalDrives()')
const GetDriveTypeW = kernel32.func('uint32_t __stdcall GetDriveTypeW(str16 lpRootPathName)')
const CreateFileW = kernel32.func(
'void* __stdcall CreateFileW(str16 lpFileName, uint32_t dwDesiredAccess, uint32_t dwShareMode, void *lpSecurityAttributes, uint32_t dwCreationDisposition, uint32_t dwFlagsAndAttributes, void *hTemplateFile)',
)
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)')
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
const DeviceIoControl = kernel32.func(
'bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)',
)
echteApi = {
laufwerksbuchstaben(): string[] {
const maske = GetLogicalDrives() as number
const buchstaben: string[] = []
for (let i = 0; i < 26; i++) {
if (maske & (1 << i)) buchstaben.push(String.fromCharCode(65 + i))
}
return buchstaben
},
laufwerkstyp(buchstabe: string): number {
return GetDriveTypeW(`${buchstabe}:\\`) as number
},
oeffnen(geraetepfad: string, zugriff: number): unknown {
const handle = CreateFileW(
geraetepfad,
zugriff,
FILE_SHARE_READ | FILE_SHARE_WRITE,
null,
OPEN_EXISTING,
0,
null,
)
const adresse = koffi.address(handle as Parameters<typeof koffi.address>[0])
// INVALID_HANDLE_VALUE ist (HANDLE)-1 — als vorzeichenlose 64-Bit-Zahl
// 0xFFFF…FF, nicht -1 (dieselbe Falle wie in windows.py dokumentiert).
if (adresse === 0n || adresse === 0xffff_ffff_ffff_ffffn) {
throw new Win32Fehler('CreateFileW', GetLastError() as number)
}
return handle
},
schliessen(handle: unknown): void {
CloseHandle(handle)
},
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes = 0): Buffer {
const aus = ausgabeBytes > 0 ? Buffer.alloc(ausgabeBytes) : null
const zurueck = [0]
const erfolg = DeviceIoControl(
handle,
code,
eingabe ?? null,
eingabe?.length ?? 0,
aus,
ausgabeBytes,
zurueck,
null,
)
if (!erfolg) {
throw new Win32Fehler(`DeviceIoControl(0x${code.toString(16)})`, GetLastError() as number)
}
return aus === null ? Buffer.alloc(0) : aus.subarray(0, zurueck[0])
},
}
return echteApi
}
+282
View File
@@ -0,0 +1,282 @@
// Tests der Laufwerks-Schicht — komplett ohne Windows und ohne koffi:
// disc.ts arbeitet gegen die LaufwerkApi-Schnittstelle, hier nachgebildet.
// Die Testfälle wandern sinngemäß aus dem bewiesenen Docker-Zweig mit
// (test_win_ioctl.py, test_windows.py, test_cdrom.py) — ein Treiber, der
// dieselben Fälle besteht, kennt dieselben Fallen (KONZEPT § 10).
import { describe, expect, it, beforeEach } from 'vitest'
import {
CDROM_DISK_AUDIO_TRACK,
CDROM_DISK_DATA_TRACK,
ERROR_ACCESS_DENIED,
ERROR_INVALID_FUNCTION,
ERROR_NOT_READY,
IOCTL_CDROM_DISK_TYPE,
IOCTL_DISK_GET_LENGTH_INFO,
IOCTL_STORAGE_CHECK_VERIFY2,
IOCTL_STORAGE_EJECT_MEDIA,
IOCTL_STORAGE_MEDIA_REMOVAL,
IOCTL_STORAGE_QUERY_PROPERTY,
DRIVE_CDROM,
Win32Fehler,
type LaufwerkApi,
} from '../src/kern/laufwerk/codes'
import {
angabenSpeicherLeeren,
auswerfenMitGrund,
classify,
geraeteInfo,
geraetepfad,
kennung,
listOptischeLaufwerke,
textBei,
BLURAY_MIN_BYTES,
UHD_MIN_BYTES,
} from '../src/kern/laufwerk/disc'
describe('Steuercodes — gegen die Microsoft-Dokumentation gerechnet', () => {
// Sollwerte aus der Doku (dieselbe Gegenprobe wie test_win_ioctl.py):
// ein Zahlendreher sähe sonst aus wie „das Laufwerk kann das nicht".
it.each([
['IOCTL_STORAGE_CHECK_VERIFY2', IOCTL_STORAGE_CHECK_VERIFY2, 0x002d0800],
['IOCTL_STORAGE_MEDIA_REMOVAL', IOCTL_STORAGE_MEDIA_REMOVAL, 0x002d4804],
['IOCTL_STORAGE_EJECT_MEDIA', IOCTL_STORAGE_EJECT_MEDIA, 0x002d4808],
['IOCTL_STORAGE_QUERY_PROPERTY', IOCTL_STORAGE_QUERY_PROPERTY, 0x002d1400],
['IOCTL_CDROM_DISK_TYPE', IOCTL_CDROM_DISK_TYPE, 0x00020040],
['IOCTL_DISK_GET_LENGTH_INFO', IOCTL_DISK_GET_LENGTH_INFO, 0x0007405c],
])('%s stimmt mit der Doku überein', (_name, wert, erwartet) => {
expect(wert).toBe(erwartet)
})
})
describe('classify — Typ aus Spur-Flags und Größe', () => {
it('Audio-Spur ist eine CD, egal wie groß', () => {
expect(classify(CDROM_DISK_AUDIO_TRACK, 700 * 1e6)).toBe('cd')
expect(classify(CDROM_DISK_AUDIO_TRACK | CDROM_DISK_DATA_TRACK, 0)).toBe('cd')
})
it('Datenspur wird nach Größe eingeordnet', () => {
expect(classify(CDROM_DISK_DATA_TRACK, 4.7e9)).toBe('dvd')
// Der gemessene Fall vom 28.08.2026: BD-50 mit 48.149.364.736 Bytes
// (44,84 GiB) liegt unter der 55-GiB-Schwelle und ist „bluray".
expect(classify(CDROM_DISK_DATA_TRACK, 48_149_364_736)).toBe('bluray')
expect(classify(CDROM_DISK_DATA_TRACK, 66 * 1024 ** 3)).toBe('uhd')
})
it('die Schwellen selbst', () => {
expect(classify(CDROM_DISK_DATA_TRACK, BLURAY_MIN_BYTES)).toBe('bluray')
expect(classify(CDROM_DISK_DATA_TRACK, BLURAY_MIN_BYTES - 1)).toBe('dvd')
expect(classify(CDROM_DISK_DATA_TRACK, UHD_MIN_BYTES)).toBe('uhd')
})
it('keine Spur-Info heißt unknown, nicht dvd', () => {
expect(classify(0, 33e9)).toBe('unknown')
})
})
describe('geraetepfad und kennung — hin und zurück', () => {
it('baut den Gerätepfad OHNE abschließenden Backslash', () => {
// Mit Backslash öffnet CreateFileW das Wurzelverzeichnis statt des
// Geräts — die Steuercodes scheitern dann mit ERROR_INVALID_FUNCTION.
expect(geraetepfad('G')).toBe('\\\\.\\G:')
expect(geraetepfad('g:')).toBe('\\\\.\\G:')
expect(geraetepfad('G:\\')).toBe('\\\\.\\G:')
})
it('wirft bei leerer Angabe', () => {
expect(() => geraetepfad('')).toThrow()
})
it('kennung löst den Pfad wieder auf (der /dev/G-404-Fund)', () => {
expect(kennung('\\\\.\\G:')).toBe('G')
expect(kennung(geraetepfad('e'))).toBe('E')
})
})
describe('textBei — STORAGE_DEVICE_DESCRIPTOR auswerten', () => {
it('liest die Zeichenkette am Offset', () => {
const puffer = Buffer.alloc(40)
puffer.writeUInt32LE(32, 12) // Offset an Position 12 zeigt auf Byte 32
puffer.write('HL-DT-ST\x00', 32, 'latin1')
expect(textBei(puffer, 12)).toBe('HL-DT-ST')
})
it('Offset 0 heißt „gibt es nicht"', () => {
expect(textBei(Buffer.alloc(40), 12)).toBe('')
})
it('Offset außerhalb des Puffers ist leer, kein Absturz', () => {
const puffer = Buffer.alloc(16)
puffer.writeUInt32LE(999, 12)
expect(textBei(puffer, 12)).toBe('')
expect(textBei(Buffer.alloc(2), 12)).toBe('')
})
})
// ── Die Win32-Nachbildung (Muster aus test_windows.py) ──────────────────
interface FakeVerhalten {
buchstaben?: string[]
typen?: Record<string, number>
oeffnenFehler?: number
antworten?: Map<number, Buffer | number> // Buffer = Antwort, number = Win32-Fehlercode
}
function fakeApi(verhalten: FakeVerhalten): LaufwerkApi & { protokoll: string[] } {
const protokoll: string[] = []
return {
protokoll,
laufwerksbuchstaben: () => verhalten.buchstaben ?? ['C', 'G'],
laufwerkstyp: (b) => verhalten.typen?.[b] ?? (b === 'G' ? DRIVE_CDROM : 3),
oeffnen(pfad, zugriff) {
protokoll.push(`oeffnen ${pfad} zugriff=${zugriff}`)
if (verhalten.oeffnenFehler !== undefined) throw new Win32Fehler('CreateFileW', verhalten.oeffnenFehler)
return { pfad }
},
schliessen() {
protokoll.push('schliessen')
},
steuern(_h, code, eingabe) {
protokoll.push(`steuern 0x${code.toString(16)}${eingabe ? ` ein=${eingabe.toString('hex')}` : ''}`)
const antwort = verhalten.antworten?.get(code)
if (antwort === undefined) return Buffer.alloc(0)
if (typeof antwort === 'number') throw new Win32Fehler(`DeviceIoControl(0x${code.toString(16)})`, antwort)
return antwort
},
}
}
function u32(wert: number): Buffer {
const b = Buffer.alloc(4)
b.writeUInt32LE(wert)
return b
}
function u64(wert: bigint): Buffer {
const b = Buffer.alloc(8)
b.writeBigUInt64LE(wert)
return b
}
const sofort = async (): Promise<void> => {}
beforeEach(() => angabenSpeicherLeeren())
describe('listOptischeLaufwerke', () => {
it('nimmt nur DRIVE_CDROM und baut Gerätepfade', () => {
const api = fakeApi({ buchstaben: ['C', 'E', 'G'], typen: { C: 3, E: DRIVE_CDROM, G: DRIVE_CDROM } })
expect(listOptischeLaufwerke(api)).toEqual(['\\\\.\\E:', '\\\\.\\G:'])
})
})
describe('auswerfenMitGrund — entriegeln, auswerfen, NACHSEHEN (R3)', () => {
it('meldet ok erst, wenn das Medium wirklich weg ist', async () => {
let verifyRunde = 0
const api = fakeApi({})
api.steuern = (_h, code, eingabe) => {
api.protokoll.push(`steuern 0x${code.toString(16)}${eingabe ? ` ein=${eingabe.toString('hex')}` : ''}`)
if (code === IOCTL_STORAGE_CHECK_VERIFY2) {
verifyRunde += 1
// Erst beim zweiten Nachsehen ist die Disc draußen.
if (verifyRunde >= 2) throw new Win32Fehler('DeviceIoControl', ERROR_NOT_READY)
return Buffer.alloc(0)
}
return Buffer.alloc(0)
}
const { grund, fehler } = await auswerfenMitGrund(api, '\\\\.\\G:', sofort)
expect(grund).toBe('ok')
expect(fehler).toBeNull()
// Reihenfolge: erst entriegeln (MEDIA_REMOVAL mit 0), dann auswerfen.
const reihenfolge = api.protokoll.filter((z) => z.startsWith('steuern'))
expect(reihenfolge[0]).toContain(IOCTL_STORAGE_MEDIA_REMOVAL.toString(16))
expect(reihenfolge[0]).toContain('ein=00')
expect(reihenfolge[1]).toContain(IOCTL_STORAGE_EJECT_MEDIA.toString(16))
})
it('Erfolg des Auswurf-Befehls allein ist KEIN ok — bleibt-drin', async () => {
// Der v1-Fehler: CDROMEJECT quittiert Erfolg auf verriegeltem Laufwerk
// und tut nichts. CHECK_VERIFY2 sagt hier weiter „Medium da".
const api = fakeApi({ antworten: new Map() })
const { grund } = await auswerfenMitGrund(api, '\\\\.\\G:', sofort)
expect(grund).toBe('bleibt-drin')
})
it('kein Zugriff aufs Gerät heißt kein-zugriff, wirft nicht', async () => {
const api = fakeApi({ oeffnenFehler: ERROR_ACCESS_DENIED })
const { grund, fehler } = await auswerfenMitGrund(api, '\\\\.\\G:', sofort)
expect(grund).toBe('kein-zugriff')
expect(fehler).toContain('Win32-Fehler 5')
})
it('abgelehnter Auswurf-Befehl heißt abgelehnt', async () => {
const api = fakeApi({ antworten: new Map([[IOCTL_STORAGE_EJECT_MEDIA, ERROR_INVALID_FUNCTION]]) })
const { grund } = await auswerfenMitGrund(api, '\\\\.\\G:', sofort)
expect(grund).toBe('abgelehnt')
})
it('gescheitertes Entriegeln verhindert den Auswurf-Versuch nicht', async () => {
const api = fakeApi({
antworten: new Map<number, Buffer | number>([
[IOCTL_STORAGE_MEDIA_REMOVAL, ERROR_INVALID_FUNCTION],
[IOCTL_STORAGE_CHECK_VERIFY2, ERROR_NOT_READY],
]),
})
const { grund } = await auswerfenMitGrund(api, '\\\\.\\G:', sofort)
expect(grund).toBe('ok')
})
})
describe('geraeteInfo — ready/empty/unknown, nie eine falsche Aussage', () => {
const beschreibung = (() => {
// STORAGE_DEVICE_DESCRIPTOR: Offsets bei 12/16/20/24 → Strings ab 40.
const b = Buffer.alloc(96)
b.writeUInt32LE(40, 12)
b.write('HL-DT-ST\x00', 40, 'latin1')
b.writeUInt32LE(56, 16)
b.write('BD-RE BU40N\x00', 56, 'latin1')
b.writeUInt32LE(72, 20)
b.write('1.03\x00', 72, 'latin1')
b.writeUInt32LE(80, 24)
b.write('0025114C0149\x00', 80, 'latin1')
return b
})()
it('Disc drin: ready mit Typ, Größe, Modell, Seriennummer', () => {
const api = fakeApi({
antworten: new Map<number, Buffer | number>([
[IOCTL_STORAGE_CHECK_VERIFY2, Buffer.alloc(0)],
[IOCTL_CDROM_DISK_TYPE, u32(CDROM_DISK_DATA_TRACK)],
[IOCTL_DISK_GET_LENGTH_INFO, u64(33_759_690_752n)],
[IOCTL_STORAGE_QUERY_PROPERTY, beschreibung],
]),
})
const info = geraeteInfo(api, '\\\\.\\G:')
expect(info).toMatchObject({
id: 'G',
status: 'ready',
typ: 'bluray',
groesseBytes: 33_759_690_752,
modell: 'HL-DT-ST BD-RE BU40N 1.03',
seriennummer: '0025114C0149',
grund: '',
})
})
it('leeres Laufwerk: empty, kein Fehler', () => {
const api = fakeApi({
antworten: new Map<number, Buffer | number>([[IOCTL_STORAGE_CHECK_VERIFY2, ERROR_NOT_READY]]),
})
expect(geraeteInfo(api, '\\\\.\\G:').status).toBe('empty')
})
it('Gerät nicht ansprechbar: unknown MIT Grund — nicht empty (R2)', () => {
const api = fakeApi({ oeffnenFehler: ERROR_ACCESS_DENIED })
const info = geraeteInfo(api, '\\\\.\\G:')
expect(info.status).toBe('unknown')
expect(info.grund).toContain('hält das Laufwerk fest')
})
it('der 30.08.-Fall: Medium da, aber Typ-Abfrage bricht — ready mit Grund', () => {
const api = fakeApi({
antworten: new Map<number, Buffer | number>([
[IOCTL_STORAGE_CHECK_VERIFY2, Buffer.alloc(0)],
[IOCTL_DISK_GET_LENGTH_INFO, ERROR_INVALID_FUNCTION],
]),
})
const info = geraeteInfo(api, '\\\\.\\G:')
expect(info.status).toBe('ready')
expect(info.typ).toBe('unknown')
expect(info.grund).toContain('beantwortet keine Medien-Abfragen mehr')
})
})
@@ -0,0 +1,47 @@
// AUSWURF-MESSUNG an echter Hardware — der W-1-Beweis „Auswerfen wirkt
// (nachgemessen, nicht geglaubt)". Läuft NUR auf ausdrückliches Verlangen:
//
// RIPPY_MESSUNG_AUSWURF=1 npx vitest run test/messung.auswurf.test.ts
//
// ⚠ Wirft die Disc im ersten optischen Laufwerk WIRKLICH aus. Der BU40N
// kann die Schublade nicht selbst einziehen (LOAD_MEDIA →
// ERROR_INVALID_FUNCTION, windows.py 28.08.2026) — nach dem Lauf bleibt
// der Tray offen. Deshalb getrennt von messung.laufwerk (nur lesend).
import { describe, expect, it } from 'vitest'
const messen = process.env.RIPPY_MESSUNG_AUSWURF === '1' && process.platform === 'win32'
describe.skipIf(!messen)('Auswurf am echten Laufwerk — Zustand davor und danach', () => {
it('entriegelt, wirft aus und SIEHT NACH', async () => {
const { win32Api } = await import('../src/kern/laufwerk/win32')
const { auswerfenMitGrund, geraeteInfo, listOptischeLaufwerke } = await import('../src/kern/laufwerk/disc')
const api = win32Api()
const geraete = listOptischeLaufwerke(api)
expect(geraete.length).toBeGreaterThan(0)
const geraet = geraete[0]
const davor = geraeteInfo(api, geraet)
console.log(`MESSUNG davor: status=${davor.status} typ=${davor.typ}`)
expect(davor.status).toBe('ready') // ohne Disc misst dieser Lauf nichts
const start = Date.now()
const { grund, fehler } = await auswerfenMitGrund(api, geraet)
const dauerMs = Date.now() - start
console.log(`MESSUNG auswurf: grund=${grund} fehler=${fehler ?? '-'} dauer=${dauerMs}ms`)
const danach = geraeteInfo(api, geraet)
console.log(`MESSUNG danach: status=${danach.status}`)
// Der eigentliche Beweis ist der ZUSTAND davor/danach — nicht der
// Rückgabewert des Steuercodes (windows.py: 4 → 1 am echten Gerät).
expect(grund).toBe('ok')
expect(danach.status).toBe('empty')
})
})
describe.skipIf(messen)('Auswurf-Messung übersprungen', () => {
it('läuft nur mit RIPPY_MESSUNG_AUSWURF=1 unter Windows', () => {
expect(true).toBe(true)
})
})
@@ -0,0 +1,42 @@
// MESSUNG an echter Hardware — läuft nur auf Verlangen:
//
// RIPPY_MESSUNG=1 npx vitest run test/messung.laufwerk.test.ts
//
// In der Ampel (Linux, kein Laufwerk) wird sie übersprungen; die Ergebnisse
// werden im SAVEPOINT.md mit Ausgabe dokumentiert (beweisen statt
// behaupten). NUR LESEND — kein Auswerfen, kein Verriegeln; der
// Auswurf-Beweis ist ein eigener, ausdrücklicher Lauf (messung.auswurf).
import { describe, expect, it } from 'vitest'
const messen = process.env.RIPPY_MESSUNG === '1' && process.platform === 'win32'
describe.skipIf(!messen)('Messung am echten Laufwerk', () => {
it('listet Laufwerke und liest ihren Zustand', async () => {
// koffi erst hier laden — beim Einsammeln der Tests bleibt alles rein.
const { win32Api } = await import('../src/kern/laufwerk/win32')
const { geraeteInfo, listOptischeLaufwerke } = await import('../src/kern/laufwerk/disc')
const api = win32Api()
const geraete = listOptischeLaufwerke(api)
console.log(`MESSUNG optische Laufwerke: ${JSON.stringify(geraete)}`)
expect(Array.isArray(geraete)).toBe(true)
for (const geraet of geraete) {
const info = geraeteInfo(api, geraet)
console.log(
`MESSUNG ${info.name} status=${info.status} typ=${info.typ} ` +
`groesse=${info.groesseBytes} modell="${info.modell}" ` +
`serial=${info.seriennummer}${info.grund.length > 0 ? ` grund="${info.grund}"` : ''}`,
)
expect(info.id.length).toBeGreaterThan(0)
expect(['ready', 'empty', 'unknown']).toContain(info.status)
}
})
})
// Ohne Messung ist die Datei trotzdem ein gültiges Test-Modul.
describe.skipIf(messen)('Messung übersprungen', () => {
it('läuft nur mit RIPPY_MESSUNG=1 unter Windows', () => {
expect(true).toBe(true)
})
})
+90
View File
@@ -0,0 +1,90 @@
// Tests der Disc-Wache — die Übergangs-Logik pur, und der Fehlerpfad:
// Eine gescheiterte Runde darf den Zustand NICHT löschen (R2) und muss
// gemeldet werden (R4).
import { describe, expect, it } from 'vitest'
import type { GeraeteInfo } from '../src/gemeinsam/nachrichten'
import { DiscWache, laufwerksUnterschiede } from '../src/kern/laufwerk/wache'
function geraet(id: string, status: GeraeteInfo['status'], typ: GeraeteInfo['typ'] = 'unknown'): GeraeteInfo {
return {
id,
name: `Laufwerk ${id}:`,
typ,
pfad: `\\\\.\\${id}:`,
status,
modell: '',
seriennummer: '',
groesseBytes: 0,
grund: '',
}
}
function alsMap(...geraete: GeraeteInfo[]): Map<string, GeraeteInfo> {
return new Map(geraete.map((g) => [g.id, g]))
}
describe('laufwerksUnterschiede', () => {
it('empty → ready ist eine eingelegte Disc', () => {
const ereignisse = laufwerksUnterschiede(alsMap(geraet('G', 'empty')), alsMap(geraet('G', 'ready', 'bluray')))
expect(ereignisse).toEqual([{ art: 'disc-eingelegt', geraet: expect.objectContaining({ id: 'G' }) }])
})
it('ready → empty ist eine entnommene Disc', () => {
const ereignisse = laufwerksUnterschiede(alsMap(geraet('G', 'ready')), alsMap(geraet('G', 'empty')))
expect(ereignisse).toEqual([{ art: 'disc-entnommen', geraet: expect.objectContaining({ id: 'G' }) }])
})
it('ready → unknown ist KEINE Entnahme — „konnte nicht nachsehen" (R2)', () => {
const ereignisse = laufwerksUnterschiede(alsMap(geraet('G', 'ready')), alsMap(geraet('G', 'unknown')))
expect(ereignisse).toEqual([])
})
it('neues Laufwerk mit Disc meldet beides', () => {
const ereignisse = laufwerksUnterschiede(alsMap(), alsMap(geraet('G', 'ready', 'dvd')))
expect(ereignisse.map((e) => e.art)).toEqual(['laufwerk-neu', 'disc-eingelegt'])
})
it('verschwundenes Laufwerk wird gemeldet', () => {
const ereignisse = laufwerksUnterschiede(alsMap(geraet('G', 'empty')), alsMap())
expect(ereignisse).toEqual([{ art: 'laufwerk-weg', id: 'G' }])
})
})
describe('DiscWache', () => {
it('meldet Änderungen und behält bei einem Rundenfehler den Stand (R2/R4)', () => {
let stand: GeraeteInfo[] = [geraet('G', 'empty')]
let lesenBricht = false
const meldungen: string[] = []
const fehler: string[] = []
const wache = new DiscWache(
() => {
if (lesenBricht) throw new Error('Win32 kaputt')
return stand
},
(meldung) => meldungen.push(meldung.ereignisse.map((e) => e.art).join(',') || 'stand'),
(f) => fehler.push(String(f)),
999_999,
)
wache.starten() // Runde 1: erster Stand
expect(wache.aktuellerStand()).toHaveLength(1)
expect(meldungen).toEqual(['laufwerk-neu'])
stand = [geraet('G', 'ready', 'bluray')]
wache.runde() // Runde 2: Disc rein
expect(meldungen).toEqual(['laufwerk-neu', 'disc-eingelegt'])
lesenBricht = true
wache.runde() // Runde 3: Lesen bricht — Stand bleibt, Fehler gemeldet
expect(fehler).toHaveLength(1)
expect(wache.aktuellerStand()).toHaveLength(1)
expect(wache.aktuellerStand()[0].status).toBe('ready')
lesenBricht = false
wache.runde() // Runde 4: nichts geändert — keine neue Meldung
expect(meldungen).toHaveLength(2)
wache.stoppen()
})
})