Diese Datei beschreibt die zentrale Architektur und die nicht-verhandelbaren
Invarianten der App. Sie ist die Pflicht-Lektüre, bevor strukturelle Änderungen
gemacht werden. Für die interaktive Modul-Übersicht siehe app-structure.html,
für einen Wettbewerber-Vergleich comparison.html.
Stand: v9.5.0 · ~933 TS/TSX-Module · ~271.5k LOC
Cable-Planner ist eine Electron-App mit klassischer Drei-Prozess-Aufteilung, plus einem optionalen HTTP-Renderer für Mobile-Geräte.
+-----------------------+ +-----------------------+ +-----------------------+
| main (Node) | | preload (Bridge) | | renderer (React) |
| | | | | |
| - app lifecycle |<-->| contextBridge |<-->| src/renderer/ |
| - window creation | | preload.cjs (CJS) | | React 19 + Zustand |
| - IPC handlers | | exposes | | ReactFlow + Three.js |
| - file I/O | | window.cablePlanner | | |
| - native deps | | | | |
+----------+------------+ +-----------------------+ +-----------------------+
|
| HTTP (LAN)
v
+-----------------------+
| mobileShareServer |
| node:http (ephemeral)|
| serves src/mobile/ |
+-----------------------+
Wichtig:
preload.ctsist CommonJS, nicht ESM.tsconfig.preload.jsonzwingt das. Niemals auf ESM-Imports umstellen — Electron's contextBridge braucht CJS.main/undrenderer/sind ESM ("type": "module"inpackage.json). Relative Imports inmain/brauchen.js-Endung (node16 module resolution).- Renderer hat keinen Node-Zugriff. Alles File-/Netzwerk-IO geht über IPC.
Alle IPC-Channels sind nach Domäne präfixiert. Definitionen in
src/main/ipc/*.ts, exponiert via src/main/preload.cts als
window.cablePlanner.<domain>.<action>.
| Domäne | Datei | Hauptkanäle |
|---|---|---|
project:* |
projectIpc.ts |
new, open, save, save-as, get-recent, export-viewer, import-annotations |
library:* |
libraryIpc.ts |
get-folder-path, reveal-folder, scan, write, delete |
rentman:* |
rentmanIpc.ts |
get-projects, get-project-equipment, get-equipment, add-project-equipment, add-project-file |
netbox:* |
netboxIpc.ts |
save-token, has-token, delete-token, normalize-url, test-connection, get-sites, get-racks, fetch-snapshot |
deviceLibrary:* |
deviceLibraryIpc.ts |
has-token, sign-in, verify-second-factor, current-user, sign-out, sync, propose, upload — die Gerätebibliothek (devices.zumpelars.de, §6.3b). URL je Aufruf, Token bleibt in main. |
cloud:* |
cloudIpc.ts |
call — Cloud-Projekte und Lese-Links (#871, #870) auf dem Server der Gerätebibliothek, eine Operation aus fester Liste (cloudService.ts). Gleiches Konto und Token wie deviceLibrary:*, Token bleibt in main. |
streamPreview:* |
streamPreviewIpc.ts |
snapshot — Standbild für die Stream-Vorschau am Canvas (#946), als data:-URI zurück, damit die CSP unverändert bleibt. Zwei Wege: die http(s)-Standbild-Adresse (nur image/*, 5 s / 5 MB) oder ein Bild aus dem Strom per ffmpeg (RTSP/RTMP/SRT/HLS/MJPEG, 10 s, nicht mitgeliefert → no-ffmpeg). Nur lokale Hosts (jede aufgelöste Adresse privat/Loopback/Link-Local, sonst not-local), kein SRT-Listener, höchstens zwei gleichzeitig. Zugangsdaten kommen aus streamCredential (Basic-Auth bzw. in die ffmpeg-Adresse), ffmpegs stderr wird verworfen. Der Renderer fragt erst nach einer Freigabe in der laufenden Sitzung (streamPreviewStore), nie beim Öffnen einer Datei. |
streamScope:* |
streamScopeIpc.ts |
start, stop — Live-Scopes am Gerät (larszu/lz-scopes#15). Dieselben Riegel wie streamPreview (nur lokal, nur ffmpeg-Protokolle, Zugangsdaten aus dem Schlüsselbund, stderr verworfen). ffprobe/ffmpeg-Banner liefert Größe und Farbangaben, ffmpeg dekodiert zu rohem R'G'B'A mit ausdrücklicher Matrix (in_color_matrix). Die Bilder gehen nicht über IPC, sondern über einen MessageChannelMain-Port (streamScope:port, im Preload per window.postMessage in die Hauptwelt) an Source.pushFrame von lz-scopes; höchstens zwei unquittierte Bilder je Port, sonst verworfen. Ein ffmpeg je Strom, höchstens vier Ströme, beendet mit dem letzten Port. Kein offener Port, keine CSP-Lockerung. |
testPattern:* |
testPatternIpc.ts |
screens, show, close — Testbild randlos im Vollbild auf einem Bildschirm dieses Rechners (Funktion am Display, larszu/lz-scopes#15). Der Renderer malt das Muster (lz-scopes renderPattern) und schickt ein PNG; das Ausgabefenster lädt es aus dem Temp-Ordner, ohne Skript und ohne Preload. Esc schließt. |
camera:* |
cameraIpc.ts |
connect, disconnect, send, status — die LZ Camera Bridge des Raums (project.cameraBridge, Port 9700): ein WebSocket im Main-Prozess (services/cameraBridgeClient.ts), jede Nachricht der Brücke kommt unverändert als camera:event an alle Fenster. Der Renderer schickt die Anlagendatei (lib/cameraBridgeSite.ts: Kameras mit Steuerweg, Adresse, Stream, Ausrichtung, Shots) und bedient den Kopf am Gerät (PtzControlSection); Beobachtungen (Pose, Tally, Slots) bleiben im cameraBridgeStore, nie im Projekt. Livebilder kommen als MJPEG von der Brücke (/video/<n>.mjpeg), im Browser-Build über einen Renderer-WebSocket. |
greengo:* |
greengoIpc.ts |
connect, disconnect, send, status — ein Green-GO-Gerät live über OSC/UDP (services/greengoOsc.ts, reines OSC in services/osc.ts). Geht nur, solange das Geräteskript osc-remote.gg5t läuft (Firmware ≥ 5.0.3.0255, Kanäle 1–6, Referenz: bitfocus/companion-module-greengo-intercom). Befehle /ggo/cmd/…, Zustand /ggo/state/… als greengo:event; Beobachtungen nur im greengoLiveStore. Im Browser-Build nicht verfügbar (kein UDP). |
streamCredential:* |
credentialsIpc.ts |
has, save, delete (Nachtrag #946): die aus einer Stream-Adresse herausgetrennten Zugangsdaten (benutzer:passwort@, passphrase=, token= …) je Stream und Feld unter stream-credential:<id>. Kein get: den Klartext braucht nur streamPreview im Main-Prozess. |
atem:* |
atemIpc.ts |
connect, disconnect, state, get-status, get-events, set-input-name, bulk-set-input-names, apply-mv-config, read-mv-config, apply-audio-config, discover, plus atem:event (broadcast) |
videohub:* |
videohubIpc.ts |
send (TCP zu Blackmagic Videohub) |
sync:* |
syncIpc.ts |
read-file, write-file, exists, acquire-lock, release-lock |
mobileShare:* |
mobileShareIpc.ts |
start, stop, status, setProject, Events: checksUpdate, cableAdded |
credentials:* |
credentialsIpc.ts |
get-token, save-token, delete-token, test-token (via keytar) |
streamKey:* |
credentialsIpc.ts |
get, has, save, delete je Ausspielziel (Initiative 9). Eigener Namensraum neben credentials:*, weil ein Kanal eine Domäne ist: dort wohnen die Integrationen (Rentman, NetBox), hier die Ziele des Projekts. Ein Account je Ziel (stream-key:<id>) — ein gemeinsamer Blob nähme beim Löschen eines Ziels entweder alle Keys mit oder keinen. |
graphml:* |
graphmlIpc.ts |
open-file |
print:* |
printIpc.ts |
pdf-bytes |
logs:* |
logIpc.ts |
renderer-error (Renderer → Main, one-way) |
signaling:* |
signalingIpc.ts |
LAN-Signaling-Relay für die Yjs/WebRTC-Kollaboration (#413) |
collabDiscovery:* |
collabDiscoveryIpc.ts |
Bonjour/mDNS-Discovery von Kollaborations-Peers im LAN |
receipt:* |
receiptIpc.ts |
pick, attach, read, reveal — die Belegdatei einer Auslagenzeile (Bedarf 97). Die Datei liegt in Belege/ neben dem Projekt und nicht im Projekt-File: ein Foto von zwei Megabyte in jeder .avplan verteuerte jede Speicherung und jeden Versand. Gespeichert wird unter dem SHA-256 des Inhalts, damit derselbe Beleg nur einmal liegt. Der Dateidialog läuft in main, der gewählte absolute Pfad erreicht den Renderer gar nicht; reveal zeigt den Ordner (showItemInFolder) statt die Datei zu öffnen — sie kommt von außen. |
attachment:* |
attachmentIpc.ts |
pick, present, reveal — Anhänge neben dem Projekt in Anhaenge/: Messprotokolle, Herstellerunterlagen, Konfig-Sicherungen. Dieselbe Ablage wie die Belege (util/projektAblage.ts: SHA-256-Name, Grenze zum Projektordner, atomar), aber jede Endung wird angenommen — ein Messgerät oder eine Konfig-Sicherung schreibt ihr eigenes Format. Vertretbar, weil es keinen Kanal gibt, der die Datei öffnet oder liest: nur reveal (Dateimanager) und present (liegt sie im Ordner?). Das Projekt führt nur den Verweis (anhaenge). |
showControl:* |
showControlIpc.ts |
start, stop, state, clear + Ereignis showControl:update — der eingehende OSC-Hörer (E-23). Vier Auflagen stehen im Code und nicht in der Prosa: aus als Vorgabe (dieses Modul startet nichts von selbst), je Projekt eingeschaltet, eine Adresse, die der Nutzer nennt (eine leere wird zurückgewiesen — 0.0.0.0 als Vorgabe lauscht auf jeder Schnittstelle, auch der im Kundennetz), und ein sichtbarer Befund, wenn nicht gebunden werden konnte. start gibt IMMER einen Zustand zurück, auch den gescheiterten: ein stiller Nicht-Empfang sieht aus wie „keine Cues", und das ist die Entwarnung durch die Hintertür. Gelesen wird aus dem Paket NUR die Adresse und die Länge dessen, was dahinter steht — Argumente zu entziffern hiesse, aus fremden Bytes Zahlen zu machen (Invariante 23). |
documentLog:* |
documentLogIpc.ts |
append, read, clear — das Register der ausgegebenen Dokumente (ADR-004). Es überdauert die Sitzung und gehört damit auf die Platte. |
Invarianten:
- Ein Channel = eine Domäne. Niemals einen Channel quer durch Domänen benutzen. Wenn eine neue Funktion zu keiner Domäne passt, eine neue Domäne anlegen.
- Alle Pfade auf der Main-Seite validieren. Renderer ist
nicht vertrauenswürdig — kein Renderer-Pfad darf ungeprüft an
fsgehen. - Schreibende Operationen sind atomic (siehe §5).
Vier Stores in src/renderer/store/. Jeder hat einen klar abgegrenzten Concern.
| Store | LOC | Concern | Persist |
|---|---|---|---|
projectStore.ts |
~1146 | Projekt-Daten + composeite slices (siehe §3.1.1), Autosave, Healing, Rentman-Sync | localStorage[STORAGE_KEYS.projectAutosave] + Disk via project:save |
uiStore.ts |
~1370 | Canvas-Viewport, Panel-Breiten, Edge-Routing-Defaults, Grid/Snap, Geräte-Farben, Device-Config-Library | localStorage[STORAGE_KEYS.ui] |
projectHistory.ts |
~200 | Undo/Redo-Stack (max 100), Transactions, 200ms-Coalesce | Nicht persistiert — geht beim Reload verloren |
settingsStore.ts |
~90 | Autosave-Intervall, Sync-Pfad/User, Token-Status | localStorage[STORAGE_KEYS.settings] |
projectStore.ts ist intern in 26 Slices unter src/renderer/store/slices/
zerlegt, die alle in den Haupt-Store komponiert werden:
annotationSlice cableSlice categorySlice
equipmentSlice groupPresetSlice groupPresetSpawnSlice
lifecycleSlice locationSlice metaSlice
mobileSyncSlice pendingChangesSlice revisionSlice
selectionLifecycleSlice templateSlice
Jeder Slice ist ein StateCreator<ProjectState, [], [], Slice> und bekommt
das set/get/store-Tripel vom Haupt-Store. So bleibt projectStore.ts
selbst klein (~1146 LOC, war 2178), während die Domain-spezifische Logik
isoliert testbar ist.
Invarianten:
projectStoreist Single Source of Truth für alle Projekt-Daten. Komponenten dürfen Projekt-Daten nicht lokal duplizieren oder cachen.uiStoreenthält keine Projekt-Daten. Wenn etwas mit dem Projekt gespeichert werden muss, gehört es inprojectStore.projectHistorylauscht aufprojectStore-Änderungen viauseProjectStore.subscribe. Niemals direkt im History-Store mutieren.- Coalesce-Window 200ms: schnelle Bursts (z. B. Drag-Updates) werden zu
einer Undo-Stufe zusammengefasst. Für explizit größere Operationen
(Multi-Delete, Paste, Drag-End-Batch) gibt es
projectHistory.transact(fn). - Slices mutieren über
set(state => ...)— niemals lokal cachen oder Side-Effects am Render-Pfad triggern.
Seit 2026-09-08 gibt es neben projectStore (Absicht) einen zweiten,
nicht persistierten Store für das, was Mischer und Router gerade tun.
Er ist die Renderer-Seite derselben Trennung, die lib/asBuilt.ts seit E-4
für die Dokumente führt.
Warum nicht im projectStore. Dort liefe eine Ablesung durch Undo/Redo,
durch die Autospeicherung und in die .cableplan-Datei. Eine Beobachtung,
die als Absicht gespeichert wird, ist genau der Fehler, den ADR-003 benennt
— und cable#647 hat gezeigt, wie er sich anfühlt: ein Status-Read hat die
geplante Kreuzschiene still durch das ersetzt, was der Hub im Moment tat.
Die Regel, die daran hängt (lib/signalAnimation.ts): der Canvas kennt
zwei Betriebsarten und keine dritte.
| Lage | Anzeige |
|---|---|
| kein Kontakt | Schema |
| Kontakt älter als 5 s | Schema |
| Kontakt frisch, über DIESE Strecke nichts bekannt | Schema — nicht „aus" |
| Meldung frisch | der gemeldete Zustand |
Der dritte Fall ist der, den man beim Bauen übersieht. Eine Kante als tot zu zeichnen, weil niemand sie gemessen hat, ist eine Aussage über ein ungemessenes Kabel.
Was die Bewegung bedeutet, und was nicht. Der Zustand ändert nicht die
Farbe — die gehört dem Layer und ist die Legende, nach der der Plan gedruckt
wird. Der Zustand trägt die Bewegung. Einzige Ausnahme ist down
(gedämpft), und die gibt es nur mit Beleg.
Zwei Einspeiser, zwei Hälften. useAtemTallyFeed meldet Tally (1 s,
über die offene IPC-Verbindung), useVideohubLinkFeed meldet Kreuzpunkte
(2 s, weil videohub:read-state je Aufruf eine TCP-Verbindung zu einem
Gerät im Signalweg öffnet). Fällt einer aus, räumt er nur seine eigene
Hälfte — ein toter Router ist kein toter Mischer, und wer alles leerte,
schickte den Nutzer zum falschen Gerät.
Die Grenze des Routers, ausdrücklich: ein Videohub meldet Kreuzpunkte
und nichts über anliegendes Signal. Deshalb gibt es routed als eigenen
Zustand neben carrying. Der Kreuzpunkt steht auch dann, wenn upstream die
Kamera aus ist; ihn als „Signal liegt an" zu zeigen machte aus einer
Router-Einstellung eine Aussage über die Anlage.
Dieselbe Trennung ein zweites Mal, aus einem anderen Anlass. Seit
2026-09-08 rechnet lib/circuitSolver.ts, welche Leuchte bei welcher
Schalterstellung brennt. Dafür braucht er zwei Sorten Angaben, und sie
gehören an verschiedene Orte:
| Was | Wo | Warum |
|---|---|---|
Die Verdrahtung: welches Gerät ein Wechselschalter ist (EquipmentItem.circuitKind), an welcher Klemme welcher Anschluss hängt (Port.circuitTerminal) |
projectStore, gespeichert |
Das ist der Plan. Er steht auf dem Blatt und geht durch Undo/Redo |
| Die Schalterstellung und der Dimmerwert | circuitStore, nicht persistiert |
Umlegen ist Ausprobieren, kein Planen |
Warum die Stellung nicht ins Projekt darf. Wer am Schaltbild einen
Schalter umlegt, fragt „was passiert dann". Läge die Stellung im
projectStore, wäre jedes Umlegen ein Undo-Schritt, ein Autospeichern und
eine Änderung an der Projektdatei: zwei Minuten Ausprobieren fräsen die
Undo-Historie leer, und die Datei trüge hinterher eine Schalterstellung,
die niemand entschieden hat. Dieselbe Wurzel wie beim liveStore
(cable#647).
Die Bauart wird angegeben, nie geraten. Sie aus der Kategorie zu
schliessen („Leuchte" → lamp) wäre der Namensabgleich, gegen den ADR-001
und ADR-002 stehen — und hier fällt er in die gefährliche Richtung: ein
Gerät namens „Wandleuchte" bekäme keinen Knoten, und der Rechner sagte
„brennt nicht". Das sieht aus wie eine Antwort. Ohne circuitKind ist ein
Gerät für das Schaltbild nicht vorhanden, und der CircuitChip nennt
die Zahl derer, die an einem Strom-Kabel hängen und keine tragen.
Die Klemme hängt am Port, nicht an seiner Position. Eine Wechselschaltung unterscheidet Klemme 1 von Klemme 2 — vertauscht man sie, brennt die Leuchte bei genau den umgekehrten Stellungen. Aus der Port-Reihenfolge abgeleitet wäre sie eine stille Umverdrahtung bei jedem Umsortieren (derselbe Befund wie B-33).
Was der Rechner NICHT ist: eine elektrotechnische Berechnung oder ein Sicherheitsnachweis. Der Rückleiter fehlt absichtlich — ein Wechselschaltungs-Plan zeigt den geschalteten Außenleiter.
Die dritte nicht persistierte Spur, und sie beantwortet die Frage, die bei jeder Inbetriebnahme zuerst kommt: wo kommt was an?
Der Ablauf ist der aus der Praxis: eine Quelle bekommt ein Prüfbild, jemand geht die Monitore ab. Was diese App dazu beiträgt, sind zwei Dinge — und die Grenze dazwischen ist die ganze Entscheidung:
| SOLL | Was der Plan vorsieht: lib/patternRouting.ts rechnet ab der Quelle über Blenden, Verteiler und den GEPLANTEN Kreuzpunkt der Kreuzschiene. Braucht keine Anlage, keine Verbindung, keinen Strom |
| IST | Was jemand vor dem Monitor gesehen hat. Steht hier nicht und wird nicht behauptet |
Die App hat keinen Videoeingang. Sie sieht kein Bild und kann keines sehen. Das Feld auf der Geräte-Karte ist deshalb die Erwartung und ausdrücklich beschriftet: „Erwartung laut Plan". Ein Mini-Monitor, der so täte, wäre die teuerste Sorte Falschaussage — man erkennt Farbbalken, hält sie für eine Rückmeldung und hat in Wahrheit den Plan zweimal gelesen.
Warum der NAME auf dem Bild der eigentliche Inhalt ist. Farbbalken allein
beantworten nichts: zwei vertauschte Kreuzpunkte sehen mit Balken auf beiden
Wegen völlig richtig aus. Steht auf dem Monitor „KAMERA 3", wo der Plan
„KAMERA 1" vorsieht, ist die Vertauschung in dem Moment gefunden, in dem
jemand hinsieht — ohne Messgerät und ohne zweiten Techniker am Funk.
lib/testPattern.ts erzeugt das Bild, patternRouting sagt, wo es stehen
müsste.
Gerechnet wird mit signalChains — derselben Traversierung, die die
Patchliste und die Mehr-Ebenen-Ansicht benutzen. Ein zweiter Weg durch
dieselbe Kreuzschiene wäre die Defektform zwei-rechnungen: er liefe beim
nächsten Sonderfall auseinander, und dann widersprächen sich zwei Ansichten
desselben Plans.
Die offenen Wege stehen gleichberechtigt daneben. „Von hier weiss der Plan nicht weiter" ist bei einer Inbetriebnahme die nützlichere Auskunft als eine kurze Liste, die vollständig aussieht — genau dort steht der Monitor, an dem später niemand versteht, warum kein Bild kommt.
Die Rückmeldung — und warum sie ins Projekt gehört. „Stimmt" /
„falsches Bild, es steht X drauf" / „kein Bild" / „kein Monitor" wird am
Ankunftsort erfasst und liegt als PatternCheck im Projekt: sie ist ein
BELEG mit Zeitpunkt und Prüfer, die Antwort auf „habt ihr das abgenommen?".
Dieselbe Einordnung wie TallyCheck, und der Gegenpol zur Wahl der Quelle,
die ein Vorgang ist. Angehängt, nie ersetzt — „gestern ging es, heute nicht"
ist die Auskunft, die den Fehler findet.
Der gesehene Name ist ein eigenes Feld, und daran hängt der ganze
Nutzen. „Falsches Bild" ist ein Symptom; „es steht KAMERA 3 drauf" ist ein
Befund; und wenn am anderen Monitor umgekehrt KAMERA 1 steht, ist es die
Ursache: zwei Ausgänge sind vertauscht. lib/patternDiagnose.ts macht
genau diese Verdichtung — und rät dabei nichts zurecht: der Name wird nur
über Gross-/Kleinschreibung und Randleerzeichen normalisiert, ein doppelt
vergebener Name löst gar nicht auf. Wer hier unscharf verglichen (Präfix,
„enthält", Levenshtein) machte aus einer Beobachtung eine Vermutung, und die
stünde dann als Befund da.
„Kein Monitor" ist ein eigener Wert und nicht „kein Bild": es ist ein Befund über den PLAN, nicht über das Signal. Wer ihn als „kein Bild" meldete, schickte jemanden auf die Suche nach einem Kabelfehler, den es nicht gibt.
Ungeprüfte Orte stehen in der Liste, mit eigenem Befund. Eine Liste, die nur die geprüften zeigt, sieht nach abgeschlossener Abnahme aus, sobald jemand drei von zwölf Monitoren angesehen hat.
Noch offen: die Erfassung über die Mobile-Ansicht (der Weg dafür steht:
/checks ist token-gesichert) und das Setzen von Kreuzpunkten aus dem Plan.
src/renderer/components/ ist in 37 Subdomänen aufgeteilt:
About/ Analysis/ Annotations/ Atem/ Cable/
Calculators/ Canvas/ Export/ Import/ Inventory/
Layout/ Library/ MobileShare/ Onboarding/ Patch/
Print/ Project/ Properties/ Rack/ Rentman/
Settings/ Sync/ shared/
Jede Subdomäne ist ein Feature-Cluster. Cross-Subdomain-Imports sind
erlaubt, aber bewusst halten — bevor ein neuer Cross-Import kommt, kurz
prüfen, ob das gemeinsame Konzept nach shared/ gehört.
Komponenten-Splits abgeschlossen (#306/#307):
EquipmentProperties.tsx(2314 → ~178 LOC) zerlegt in 25 Sub-Sections unterProperties/sections/— DragSortable, jede Section eigen- ständig persisited Reihenfolge.SettingsDialog.tsx(2392 → ~60 LOC) zerlegt in 9 Tab-Komponenten unterSettings/tabs/— ProjectTab, AppearanceTab, EditingTab, HotkeysTab, IntegrationsTab, ConfigsTab, ModulesTab, SyncTab, AdvancedTab.
Top-Files heute (>1500 LOC, weitere Refactor-Kandidaten):
CanvasArea.tsx(~1980),RackBuilderDialog.tsx(~1800),RentmanImportDialog.tsx(~1780). Knapp darunter:LibraryPanel.tsx(~1430),VideohubExportDialog.tsx(~1390),AtemMvConfigDialog.tsx(~1320),CanvasToolbar.tsx(~1270).
ReactFlow 11 ist die Engine. Eigene Erweiterungen:
EquipmentNode.tsx(Custom-Node mit Port-Handles)CableEdge.tsx(Custom-Edge mit Waypoints, Auto-Routing, Label-Slider)LocationNode.tsx(Rahmen mit Move-Contents-Logik)LayerVisibilityChips.tsx(Layer-Filter mit Count-Badges)pathfinding.ts(Orthogonal-Routing zwischen Ports). Das Zellmaß ist ein Parameter, keine Konstante — es kommt auslib/raster.tsund ist gleich der eingestellten Rastergröße (Invariante 24).cableApproach.ts(die Anfahrt an das Geraet: Stummel an beiden Enden, Form gewaehlt statt angenommen — der Weg macht nicht kehrt, und der Pfeil faehrt gerade in die Buchse). Wer eine zweite Stelle baut, an der ein Kabelweg zusammengesetzt wird, hebelt das aus:CableEdge.tsxhatte genau deshalb zwei Fassungen, und nur eine setzte einen Stummel.
@react-three/fiber (R3F) + three.js für die 3D-Rack-Ansicht in Rack/.
STL-Export via three-stdlib. Keine Three-Imports außerhalb von Rack/
— sonst zieht es die ~600 KB Three-Library in den Hauptbundle.
Zentral in src/renderer/lib/i18n.ts. Hook useTranslation() gibt
t(key, fallback), format(template, values) interpoliert {name}.
Konventionen:
- Deutsche Strings sind die Quell-Sprache — Fallback in jedem
t()-Call ist deutsch. Englisch-Übersetzungen liegen imen-Dict. - ~2000 Keys decken die UI ab (Settings, Dialoge, Properties, Export, Patch-Liste, ATEM/Videohub, Rentman-Sync, Onboarding, Inspector).
- Class-Komponenten (ErrorBoundary) nutzen
translate(lang, key, fallback)mituseUiStore.getState().languagestatt Hook. - Sub-Komponenten innerhalb einer Datei brauchen eigene
const t = useTranslation()-Zeile.
Bilinguale Kategorien (#309):
lib/categoryTranslations.tsverwaltet eine Map vom canonical- Kategorie-Key (=knownCategories[]-Eintrag) auf{de, en}Anzeige- Labels.lib/bilingualCategoryDialog.tsxist der Prompt mit zwei Sprach-Feldern (aktive UI-Sprache oben), wird inCategorySelect.tsxundAdvancedTab.tsx/LibraryPanel.tsxals Rename-Dialog genutzt.categoryDisplay(canonical, lang, map)resolved den Anzeigenamen — mit Built-in-Übersetzungen für die 13 DEFAULT_CATEGORIES als Out-of-the- Box-Fallback.
Definiert in src/renderer/types/.
CablePlannerProject
├── metadata: ProjectMetadata # Name, Author, Client, Logos, Defaults
├── equipment: EquipmentItem[] # Geräte mit Ports
├── cables: Cable[] # Verbindungen zwischen Ports
├── locations: LocationFrame[] # Räume / Bereiche (Rahmen mit Inhalt)
├── canvasState: { viewport, ... } # Pan/Zoom
├── annotations: ProjectAnnotation[] # Notizen / Markups
├── intercom?: IntercomPlan # Intercom-Slot (E-2); GreenGoConfig ist seine Projektion
├── checkState? # Mobile-View-Häkchen
├── mode: 'editing' | 'finalized' | 'viewer'
├── sourceIdentities?: SourceIdentity[] # ADR-001 — Rollen („Kamera 1")
├── deliveryDestinations?: DeliveryDestination[] # Initiative 9 — OHNE Stream-Keys
└── viewerSession? # Read-only-Hash
EquipmentItem (Auszug):
id,templateId?(Library-Verweis),category(camera, switcher, monitor, ...)inputs[], outputs[]alsPort[]mitconnectorType(XLR, BNC, HDMI, Fiber, SFP+, Ethernet, ...)position,size,nodeColor?,rackMode?,rackInternalSnapshot?modes?: DeviceMode[](#113) — verschiedene Port-Layouts pro GerätatemMvConfig?,atemAudioConfig?(ATEM-Mischer spezifisch)
Cable (Auszug):
from/toEquipmentId,from/toPortId,type(Connector-Typ)length,routing,waypoints[],arrow*,bidirectionallayer(auto-detected austypefalls leer),labelT,labelHiddenwireless,frequency,maxRange(für Funk-Strecken)cableSpecId?(Verweis auf eine eindeutige Kabel-Definition aus der Library für BOM-Aggregation)
DeliveryDestination (Initiative 9, types/delivery.ts):
platform,transport(SRT/RTMP/HLS),ingestUrl?,account?encoding: EncodingProfile— die sechs Felder, die zwischen Primär- und Backup-Weg übereinstimmen müssen: Auflösung, Video-Codec, Bitrate, Bildrate, Keyframe-Abstand, Audio-Abtastrate. Belegt bei YouTube und Castr; driften sie auseinander, bricht der Failover.backupOfId?— zeigt auf das Ziel, dessen Ausweichweg dieses ist. Die Richtung ist Absicht: einbackupIdam Primärziel liesse zwei Backups nicht zu und würde bei gelöschtem Backup zum Fehlzeiger.hasStreamKey?— eine Tatsache über diesen Rechner, kein Wert. Der Key selbst liegt viakeytarunterstream-key:<id>; er steht nie im Projekt, weil eine.avplanper Mail wandert, in Dropbox liegt und in den Mobile-/Web-Viewer geht. Beim Laden wird das Häkchen nachgefragt, nicht geglaubt.encoderEquipmentId?(Bedarf 32) — die einzige Naht zwischen Ziel-Register und Plan. Zeigt auf dasEquipmentItem, das dieses Ziel beliefert. Alles Weitere ist abgeleitet und wird nicht gespeichert: der Programm-Eingang des Encoders kommt aus seinen Anschlüssen, die Quelle aus der Rückwärtssuche im Kabelgraph (labelDerivation.resolveSignalSource, ADR-001). Die Ableitung steht inlib/deliveryPath.tsund erzeugt das Blattausspielweg.- Optional, und das bleibt es. Ohne Angabe meldet die Kette
no-encoderstatt sich einen Encoder auszusuchen. Ein Zeiger auf ein gelöschtes Gerät wird beim Laden nicht stillschweigend geleert —encoder-goneist die ehrlichere Antwort als ein Feld, das kommentarlos leer wird. - Die Encoder-Machbarkeit (
lib/encoderFeasibility.ts) zählt seither je Gerät statt über den ganzen Plan. Vier Ziele auf zwei Maschinen sind je Maschine zwei; die frühere Summe meldete „vier gleichzeitige Ziele, vMix führt drei" auf einem korrekten Aufbau.
- Optional, und das bleibt es. Ohne Angabe meldet die Kette
NetworkInterface (Bedarf 19, types/network.ts):
role(media-primary|media-secondary|control|management|unspecified),ipAddress?,subnetMask?,gateway?,macAddress?,vlanId?,switchEquipmentId?,switchPort?,portId?- Die vier Netz-Felder am Gerät SIND Schnittstelle 0;
networkInterfaceshält 1..n. Es gibt also je Adresse genau ein Zuhause — keine Spiegelung. Wer ALLE Schnittstellen braucht, nimmt die Engstellelib/networkInterfaces.ts#deviceInterfaces, nichtitem.ipAddress. Der Grund für die Bauform steht intypes/network.ts:ipAddresssteht an 95 Stellen in 36 Dateien, und ein Umzug in einem Schritt hätte jede übersehene Stelle stillundefinedlesen lassen. roleist ohne Angabeunspecified— geraten wird nicht: ob die eine IP einer Kamera ihre Steuerung oder ihr Medienweg ist, weiss der Plan nicht.
LocationFrame:
id,name,x,y,width,height,colormoveContents?— wennfalse, bewegt sich Inhalt nicht beim Drag. Default isttrue(heal setzt fehlende Werte auftrue).
Pflicht-Pattern für jeden Schreibvorgang in src/main/util/atomicWrite.ts:
- Existiert In-Flight-Lock für
targetPath? → Fehler. - Schreibe in
<targetPath>.<random>.tmp. - Wenn
targetPathexistiert: rotiere<targetPath>.bak. rename(tmpPath, targetPath)(atomic auf POSIX).- Bei Fehler: tmp aufräumen, Lock immer freigeben.
Nutzer: project:save, library:write, sync:write-file.
Niemals direkt fs.writeFile für persistente Daten — Crash-Mid-Write
würde sonst das Projekt zerstören.
projectStore.loadProject() → healProjectPositions(project):
- Runden alle Positionen auf Integer (kein Float-Drift).
- Fehlende
layerauf Cables → Auto-Detect ausconnectorType. - Fehlende
moveContentsauf Locations →true. - Fehlende Arrays (
cables,locations,annotations) → leeres Array. - Ungültige Port-IDs werden ge-loggt, aber nicht entfernt (User-Daten nicht stillschweigend löschen).
Heal ist die Schema-Migrationsschicht. Neue optionale Felder mit Default gehören hier rein, nicht in einzelne Komponenten.
| Daten | Wo | Format |
|---|---|---|
| Projekt-Datei | User-gewählter Pfad | .cableplan (JSON, atomic + .bak) |
| Autosave | localStorage[projectAutosave] |
JSON |
| Library | userData/library/{devices,groups}/*.cpdevice|.cpgroup |
JSON |
| UI-State | localStorage[ui] |
JSON |
| Settings | localStorage[settings] |
JSON |
| Window-Geometrie | userData/window-geometry.json |
JSON |
| Rentman-Token | OS-Credential-Store via keytar |
OS-eigen |
| Stream-Keys der Ausspielziele | OS-Credential-Store via keytar, Account stream-key:<ziel-id> |
OS-eigen |
| Sync-Lock | <shared-pfad>/.cable-planner-sync.lock |
JSON (TTL 2h) |
| Kategorie-Übersetzungen | localStorage[categoryTranslations] |
JSON-Map |
| Beobachtungen (Tally, Kreuzpunkte) | nirgends — liveStore, nur im Speicher |
— |
| Schalterstellungen im Schaltbild | nirgends — circuitStore, nur im Speicher |
— |
| Gewählte Prüfbild-Quelle | nirgends — patternStore, nur im Speicher |
— |
| Sichtprüfungen vom Prüfbild-Rundgang | im Projekt (patternChecks) |
— |
Die letzten beiden Zeilen stehen hier, weil sie Entscheidungen sind und keine Versäumnisse. Was die Anlage vor einer Stunde tat, weiß diese App nach einem Neustart nicht mehr, und das ist die richtige Aussage — ein persistierter Beobachtungsstand sähe beim nächsten Öffnen aus wie ein aktueller. Und wie die Schalter beim letzten Ausprobieren standen, will niemand wiederhaben; gespeichert wäre es eine Angabe, die niemand entschieden hat.
atem-connection npm-Package · UDP-Protokoll im LAN.
Invarianten in atemIpc.ts:
connectInFlight-Lock: paralleleatem:connect-Calls werden serialisiert. Niemals zweinew Atem()parallel — UDP-Packets kreuzen sich sonst.removeAllListeners()vordisconnect()inensureDisconnected.- Promise-Handshake mit 5s-Timeout statt Polling-Schleife.
Audio-Routing (#258): Profile-XML in/out + Direct-Send via
atem:apply-audio-config. Crosspoint-Matrix oder klassischer Mixer im
gleichen XML; Mixer-Sektion wird Round-Trip-erhalten.
Multiviewer (#288): atem:read-mv-config holt den Live-Stand vom
verbundenen Switcher, apply-mv-config schreibt zurück.
HTTP-API in services/rentmanApiClient.ts. Token im OS-Credential-Store.
Niemals Token loggen oder ins Projekt-File schreiben.
Lesende REST-Anbindung an eine eigene NetBox-Instanz, um eine dort
geplante Site oder ein Rack als Kabelplan zu übernehmen
(services/netboxApiClient.ts, Referenz unter
<instanz>/api/schema/swagger-ui/).
Anders als bei Rentman gibt es keine feste Cloud-URL. Die Basis-URL lebt
deshalb in den App-Settings (settingsStore.netboxUrl) und wird bei jedem
IPC-Aufruf mitgegeben; validiert wird sie immer in main
(normalizeNetboxBaseUrl: nur http/https, /api…-Suffix und Query werden
abgeschnitten). Der Main-Prozess bleibt damit zustandslos. Das API-Token
liegt via keytar unter dem Account netbox-api-token und wird — anders
als der Rentman-Token — nie an den Renderer zurückgegeben; der fragt
nur has-token.
Genutzte Endpoints (alle nur lesend): /api/status/, /api/dcim/sites/,
/api/dcim/racks/, /api/dcim/devices/, die sieben Komponenten-Endpoints
(interfaces, front-ports, rear-ports, console-ports,
console-server-ports, power-ports, power-outlets) und
/api/dcim/cables/. Paginiert wird über limit/offset statt über die
next-URL — hinter einem Reverse-Proxy zeigt die auf interne Hostnamen.
Mapping (lib/netboxMapping.ts, rein und unit-getestet):
- NetBox-Komponente → Cable-Planner-Port. Richtungslose Interfaces werden
als gespiegeltes In/Out-Paar angelegt (beide Ports tragen dieselbe
netboxId), damit jedes Kabel als Ausgang→Eingang zeichenbar bleibt. Strom/Konsole/Patchfeld haben in NetBox eine echte Richtung und bekommen genau einen Port. - Default
onlyConnectedPorts: true— ein 48-Port-Switch brächte sonst 96 Handles auf den Knoten, von denen im Rack eine Handvoll gepatcht ist. - Layout: eine Spalte je Rack, innerhalb der Spalte nach Höheneinheit
absteigend gestapelt, optional ein
LocationFrameje Rack. Der Block landet rechts vom Bestand, überdeckt also nie einen vorhandenen Plan.
Der Abgleich ist per Konstruktion additiv. NetBox ist die Wahrheit über
die Verkabelung, der Cable Planner über die Darstellung (Positionen,
Farben, Wegpunkte, Labels, Multicore-Bündel). Ein erneuter Lauf legt nur
an, was über netboxId noch nicht im Plan ist, und ergänzt an bestehenden
Geräten ausschliesslich neu hinzugekommene Ports. In NetBox gelöschte
Elemente werden als staleDeviceIds/staleCableIds gemeldet, nicht
entfernt — das Aufräumen bleibt beim Planer. Angewendet wird der Plan
atomar über applyNetboxImport (slices/netboxImportSlice.ts), damit der
Undo-Stack einen Import als einen Schritt sieht.
Nicht zu verwechseln mit lib/netboxImport.ts — das ist der ältere
Import einzelner Gerätetypen aus der öffentlichen
netbox-community/devicetype-library auf GitHub (statische YAML), ohne
eigene Instanz.
Gemeinsamer, moderierter Gerätekatalog der Suite (Repo
larszu/av-device-library). Nur mit Konto nutzbar; Konten entstehen auf der
Website. Der Client ist eine unveränderte Kopie von
clients/deviceLibraryClient.ts aus dem Bibliotheks-Repo und liegt zweimal
hier: src/main/services/ (Desktop) und src/renderer/lib/ (Web-Build);
tests/deviceLibrary.test.ts hält beide Kopien gleich.
- Abruf im Main-Prozess (
services/deviceLibraryService.ts). Zwei Gründe: das Bearer-Token verlässt main nicht (wie bei NetBox), und die Server-URL ist änderbar — die CSP des Fensters kennt nur feste Ursprünge, main unterliegt ihr nicht.https://devices.zumpelars.desteht trotzdem inconnect-src, für den Renderer-Weg ohne Preload-Brücke. - Token: Desktop im Schlüsselbund (
keytar, Accountdevice-library-token); Web-Build unter einem eigenen localStorage-Schlüssel (deviceLibraryWeb.ts). Nie im Projekt, nie im Log. - URL:
settingsStore.deviceLibraryUrl, leer =DEFAULT_DEVICE_LIBRARY_URL. Geprüft (nur http/https) in main. - Abgleich (
lib/deviceLibrary.ts, rein und getestet):sync('cable', latestSeq)inkrementell;removedentfernt; jeder Eintrag läuft durchpruefeVorlageund wird bei blockierendem Befund übersprungen und gezählt. Kennt der Server einen kleinerenlatestSeqals gemerkt, wird alles neu geholt. Stand undlatestSeqliegen unterSTORAGE_KEYS.deviceLibraryCache— nicht incustomLibrary: die Bibliothek ist eine eigene, schreibgeschützte Quelle (store/deviceLibraryStore.ts, Bibliothek → Equipment → „Shared"). - Einreichen:
DeviceLibrarySubmitDialogbaut aufbaueEinreichungauf; Hersteller/Modell trennt der Nutzer (die Vorlagen kennen nur einen Namen),sourceUrlistmanufacturerUrl. Die Trennung wird gemerkt und gilt fürs Hochladen. - Hochladen (
lib/deviceLibraryUpload.ts, rein und getestet): eigene Vorlagen =customLibraryohne Rentman-Importe und ohne unveränderte Vorlagen ausEINGEBAUTER_KATALOG(Favorit/Versteckt zählen nicht). Je Vorlage wird der Fingerabdruck der hochgeladenen Fassung gemerkt (STORAGE_KEYS.deviceLibraryUploads, dazu Zustand, Slug, Befunde und die Hersteller/Modell-Trennung); nur Geändertes geht perupload('cable', …)raus, nacherrorerneut, und dazu alles, was lautmoderationnochpendingist — der Server meldet den Moderationsstand auch beiin-sync, so wird „wartet" zu „live".lib/deviceLibraryAuto.tsstartet im Hauptfenster: beim Start und 5 s nach einer Änderung ancustomLibraryerst hoch, dannsync— nur mit EinstellungdeviceLibraryAutoUpload(Vorgabe an) und angemeldet. - Katalog veröffentlichen:
scripts/library-publish.mjslädteingebauterKatalog.tsunddeviceLibraryItem.tsdirekt in Node (deshalb dort nur Typ-Importe und Importe mit.ts-Endung) und lädt mit einem Admin-API-Schlüssel hoch; Workflowlibrary-publish.yml.
fast-xml-parser parst yEd-XML. Sicher gegen XXE —
fast-xml-parser ignoriert DTDs/external entities per default.
videohubIpc.ts öffnet eine TCP-Verbindung zum Videohub und sendet
plain-text Routing-/Label-Blöcke. Smart-Routing erkennt Quellen anhand
ihrer Namen (Fuzzy-Match mit AI-Provider-Fallback bei niedriger
Score-Schwelle).
Jede Datei, die dieses Programm an ein fremdes Gerät ausgibt — Videohub
(Routing und Beschriftungen), Green-GO .gg5, ATEM-Audio-XML, die Geräteliste
für tally-pi — geht über lib/deviceConfigExport.ts#exportDeviceConfig und
bekommt ein zweites File daneben: <datei>.herkunft.txt mit Projekt, Stand,
Dokument-Stempel (ADR-004), App-Version und einer Prüfsumme über den Inhalt
der Konfigurationsdatei.
Die Gerätedatei selbst wird nicht angefasst. Ob Blackmagics Videohub Setup
eine #-Zeile überliest, ob der Green-GO-Editor ein unbekanntes JSON-Feld
durchlässt, ob der ATEM-Importer ein zusätzliches Kommentar akzeptiert — das
steht ohne die Hersteller-Spezifikation nicht fest, und die liegt hier nicht
vor. Dass unser Parser (parseVideohubLabelsTxt) #-Zeilen überspringt,
sagt nichts über das Gerät: die Datei geht dorthin, nicht zu uns zurück. Eine
Konfiguration, die das Pult beim Laden zurückweist, ist beim Load-in schlimmer
als eine ohne Herkunft.
Das Blatt geht zuerst raus, die Konfiguration danach: bricht der Browser die zweite Ausgabe ab, fehlt das Blatt und nicht die Datei, die die Show braucht.
deviceConfigProvenance.ts ist rein (keine Uhr, kein Store);
deviceConfigExport.ts setzt Stempel, Uhr und Version zusammen und ist die
einzige unreine Zeile des Wegs.
mobileShareServer.ts startet einen node:http-Server auf ephemerem Port
(kein Express — die App hat kein Web-Framework als Abhängigkeit),
liefert src/mobile/ an Smartphones im LAN. Bidirektional:
- Main → Mobile: aktuelle Projekt-Snapshot (Pull-Endpunkt), Passwörter und
Schlüssel vorher via
stripSecretsentfernt. - Mobile → Main: fünf Schreibwege, nicht einer —
Bauteam-Häkchen (POST
/checks), neu angelegte Kabel (POST/cables, v7.9.54), Feld-Rückmeldungen (POST/pending-changes), die Sichtprüfung vom Prüfbild-Rundgang (POST/pattern-checks, B-42 Inkrement 2b) und Fotos (POST/fotos, #884). Alle fünf sind token-gated (authed, Token aus der QR-Code-URL), gehen durchwriteAllowed(Bedarf 109) und durchshowOk(Bedarf 127). Der fünfte hat als einziger eine Obergrenze in Megabyte statt in Kilobyte: ein Foto ist gross, und es wird schon auf dem Telefon heruntergerechnet (1600 px lange Kante) — die 4 MB sind der Deckel gegen ein Telefon, das das nicht tut, nicht das erwartete Mass. Der vierte ist bewusst KEIN Zweig von/checks: der dort geschickteCheckStateist ein vollständiger Zustand und ersetzt den vorigen — richtig für Häkchen, falsch für eine Beobachtung, die angehängt gehört. - Main → Mobile, zusätzlich: die Prüfbild-Erwartung (GET
/pattern.json), im Renderer auspatternRoutinggerechnet und hier nur gehalten. Eine zweite Traversierung auf dem Telefon wärezwei-rechnungen.
Mobile ist kein Editor — aber auch nicht read-only: die fünf Wege oben ändern das Projekt am Desktop. Wer das anders formuliert findet, korrigiert es; der Dialog-Hinweis sagte bis v7.9.x fälschlich „kann nur lesen, nichts schreiben", was für eine Sicherheits-Entscheidung des Nutzers die falsche Grundlage war. Wenn Mobile echter Editor wird, braucht es eine richtige API-Schicht statt File-Push.
lib/aiSuggestions.ts unterstützt Gemini, Claude und OpenAI
Keys (user-supplied, persistiert pro-Provider in localStorage). Wird für
Port-Vorschläge bei neuen Geräten und Smart-Routing-Fuzzy-Matching genutzt.
Scripts (package.json):
dev—concurrentlystartet Vite + 3× tsc-watch (main/preload/renderer) + Electron.build—tsc -p tsconfig.main.json && tsc -p tsconfig.preload.json && vite build.dist—build+electron-builder→ Installer inrelease/.
Versions-Quelle: einziger Eintrag in package.json → version.
Vite injiziert ihn build-time als __APP_VERSION__ in den Renderer.
About-Dialog, StatusBar, ErrorBoundary, main.tsx lesen alle daraus —
nirgendwo hardcoded.
electron-builder.js:
- macOS: Universal DMG (x64 + arm64), ad-hoc signiert.
- Windows: NSIS-Installer + portable EXE (x64).
npmRebuild: truerebuildetkeytarund@julusian/freetype2für Electron-ABI.
Release-Workflow (manuell):
package.jsonversionbumpen.- Commit + Tag
vX.Y.Z+ Push (Tag triggert CI-Build). - GitHub Release mit Auto-Generated Notes + Installer-Artefakte.
Native Deps (achten!):
keytar— OS-Credentials (Rentman-Token, NetBox-Token, Stream-Keys).@julusian/freetype2— transitiv viaatem-connection, nicht via Three und nirgends direkt importiert (grep -rn freetype src/ist leer). Er steht hier trotzdem, weilnpmRebuildihn für die Electron-ABI neu bauen muss.electron-rebuildmuss nach jedem Electron-Update laufen.
Das Wichtigste in Listenform. Niemals brechen ohne expliziten Architektur-Review.
-
Atomic Writes via
atomicWriteFile— niemals direktfs.writeFilefür Userdaten. -
healProjectPositionsläuft auf jedes geladene Projekt — Schema-Migration immer dort. -
IPC-Channels sind domain-präfixiert und in
src/main/ipc/<domain>Ipc.tsdefiniert. -
preload.ctsbleibt CommonJS — Electron's contextBridge braucht das. -
Pfad-Validierung passiert in
main, nie im Renderer. -
projectStoreist Single Source of Truth für Projekt-Daten. -
Three.js bleibt hinter der Lazy-Grenze — Bundle-Size-Schutz. Der Import-Ort ist dabei nicht das Kriterium: solange ein statisch importiertes Modul nach
Rack/hineinreicht, liegt Three im Haupt-Chunk, egal wie diszipliniert die Importe sind. Genau so war es — bis auflib/exportRack.tsstanden alle Three-Importe brav inRack/, undLibraryPanelzog denRackBuilderDialogstatisch herein. Die beiden Eintritte (RackBuilderDialog,RackEditorDialog) sind deshalblazyund werden nur gemountet, wenn sie offen sind; gemessen 4.193 → 2.938 kB (gzip 1.165 → 822).tests/threeBundleGrenze.test.tshält das fest. -
Connection-Locks bei externen Services (ATEM
connectInFlight). -
Patch-Versionen bevorzugt — keine großen Sprünge (Standing User Directive).
-
Keine Emojis im Code außer auf expliziten Wunsch.
-
Version lebt nur in
package.json— überall sonst gelesen via__APP_VERSION__(Vite-Define). -
Deutsche Strings sind Quell-Sprache — Fallback in
t(key, fallback)immer deutsch, EN-Übersetzungen imen-Dict. -
Geheimnisse stehen nie im Projekt — Rentman-Token, NetBox-Token und die Stream-Keys der Ausspielziele liegen im OS-Credential-Store via
keytar. Das Projekt trägt höchstens die Tatsache, dass eines hinterlegt ist, und die gilt für den Rechner, auf dem sie gelesen wird: beim Laden wird sie nachgefragt, nicht aus der Datei geglaubt. Der Grund ist der Weg der Datei — eine.avplanwandert per Mail, liegt in Dropbox und geht in den Mobile- wie in den Web-Viewer. -
Der Canvas behauptet keinen Anlagenzustand ohne frischen Beleg. Eine Animation, die aussieht wie fließendes Signal, IST eine Aussage über die Anlage; niemand liest daneben eine Zahl. Ohne frische Beobachtung zeigt der Canvas das Schema und sagt das auch (
FlowModeChip) — und „nichts bekannt" ist ausdrücklich nicht dasselbe wie „aus". Beobachtungen liegen imliveStoreund nie im Projekt. Wer eine dritte Quelle anschließt, hält sich an dieselbe Grenze: melden, was das Gerät WIRKLICH sagt (routedist nichtcarrying), mit Zeitstempel, und beim Ausfall nur die eigene Hälfte räumen. -
Was der Nutzer ausprobiert, ist keine Planänderung. Die Schalterstellungen des Schaltbilds liegen im
circuitStoreund nie im Projekt; ein Klick auf einen Schalter erzeugt keinen Undo-Schritt, keine Autospeicherung und keine Änderung an der Datei. Die VERDRAHTUNG dagegen ist Plan und steht im Projekt (circuitKind,circuitTerminal). Wer eine weitere Probier-Ansicht baut — eine zweite Ausspiel-Variante, ein „was wäre wenn" auf der Kreuzschiene — trennt genauso: das Ergebnis darf gerechnet und gezeigt werden, die Eingabe dafür wird nicht gespeichert, und die Ansicht sagt, dass sie gerechnet ist. -
Ein BILD auf dem Plan ist die gefährlichste Behauptung von allen. Ein Vorschaufeld auf einer Geräte-Karte sieht aus wie eine Rückmeldung von diesem Gerät, und Farbbalken sehen überzeugend nach „Signal ist da" aus. Diese App hat keinen Videoeingang — was sie zeigt, ist die Erwartung aus dem Plan und trägt diese Beschriftung am Feld selbst, nicht nur im Streifen. Wer ein weiteres Vorschaufeld baut, hält sich daran: entweder es kommt aus einer belegten Quelle mit Zeitstempel, oder es ist als Erwartung beschriftet. Es gibt keine dritte Möglichkeit, und „sieht man doch" ist keine — die ganze Schwierigkeit ist, dass man es eben nicht sieht.
-
Ein Befehl an eine laufende Anlage nennt nur, was er meint. Wer aus dem Plan heraus schaltet, sendet GENAU die Kreuzpunkte, um die es geht — nie den ganzen Zustand des Geräts. Der Unterschied ist kein Stilfrage:
buildVideohubRoutingCommandschreibt eine Zeile für jeden Ausgang und setzt fehlende Einträge auf Eingang 0, was für einen vollständigen Export richtig und für „schalte Ausgang 7" das Schwarzschalten fremder, womöglich sendender Ausgänge wäre.buildCrosspointCommandhat deshalb keintotalOutputsund keinen Default: ein Ausgang, über den niemand etwas gesagt hat, kommt im Befehl nicht vor. Dazu drei Bedingungen, die für jeden weiteren Steuerweg gelten (ATEM, Beleuchtung, was auch immer): der Nutzer liest vor dem Bestätigen den Klartext mit Namen und den wortwörtlich gesendeten Text; jeder Versuch wird als Beleg im Projekt festgehalten, auch der gescheiterte („wer hat geschaltet?" ist die Frage, die er beantwortet); und der Plan wird dabei nicht nachgezogen (Invariante 14 in die andere Richtung — zöge das Senden den Plan mit, gäbe es hinterher keine Abweichung mehr zu sehen). -
Ein Protokoll, das nicht belegt ist, wird nicht nachgebaut. Die Versuchung ist gross: die meisten Mischer und Kreuzschienen sprechen zeilenorientierten Text, und die Zeile „weiss man doch". Man weiss sie nicht — die verbindliche Beschreibung steht im Handbuch des Geräts, und frei zugängliche Nachbauten sind Nachbauten (in einem davon hängt der Sender an jeden Befehl ein Semikolon, das im Befehl schon steht). Ein aus dem Gedächtnis geschriebener Treiber ist deshalb keine Bequemlichkeit, sondern eine ungeprüfte Zusicherung, die als Befehl an eine laufende Anlage geht. Wo eine Beschreibung vorliegt, gehört ein eigener Treiber her; wo nicht, trägt der NUTZER die vier Angaben ein, die im Handbuch stehen (
lib/textProtocol.ts), und die App zeigt vor dem Senden, was rausgeht — Steuerzeichen benannt. Eine mitgelieferte Vorlage trägt ihre Herkunft im Klartext und behauptet nie, vom Hersteller zu stammen, wenn sie es nicht tut. -
Wer das Protokoll nicht kennt, delegiert — und sagt, an wen. Bitfocus Companion (MIT) pflegt rund fünfhundert Hersteller-Module, jedes von Leuten mit dem Gerät auf dem Tisch. Das ist die bessere Antwort auf „alle Hersteller" als jeder eigene Nachbau, und
switcherControl/ companionDriver.tsnutzt sie: zwei Custom-Variablen setzen, dann die eine Schaltfläche drücken, deren Route-Aktion sie liest. Die Reihenfolge ist dabei die ganze Zusicherung. Schlägt eine Variable fehl, darf der Druck NICHT passieren — sonst feuert die Schaltfläche mit den Werten von vorhin und schaltet den vorigen Kreuzpunkt, auf einer laufenden Anlage, und es sieht aus wie ein gelungener Befehl. Deshalb steht die Folge als Datenstruktur (CompanionSchritt[]mitabbruchBeiFehler) und nicht als Ablauf im Treiber: einen Ablauf baut jemand um, ohne die Folge zu bedenken. Und die Rückmeldung bleibt genau: „Companion hat die Aufrufe angenommen" ist NICHT „das Gerät hat geschaltet" — was hinter der Schaltfläche passiert, meldet Companion an dieser Stelle nicht zurück. -
Ein Befehl an einen Prüfstand ist im Beleg als solcher zu erkennen. Zum Prüfen, ob ein geschalteter Weg dort ankommt, wo der Plan ihn erwartet, gibt es Emulatoren, die das Protokoll sprechen (
docs/atem-pruefstand.md). Sie sind die richtige Antwort auf „ich will schalten, aber nicht an der laufenden Anlage" — und sie schaffen genau eine neue Verwechslungsgefahr: eine Probe am Emulator sieht inproject.hubSwitchesaus wie ein Eingriff. Gleiche Uhrzeit, gleicher Gerätename, gleiche Nummern, gleiches „angenommen". Der Datensatz beantwortet aber die Frage „wer hat den Ausgang umgeschaltet?", gestellt nach einer Sendung von jemandem, der nicht dabei war — und eine Probe, die dort als Eingriff steht, schickt ihn an eine Stelle, an der nie jemand war. Deshalb: das Ziel wird erklärt (EquipmentItem.controlTarget), nicht aus der Adresse geschlossen — ein Prüfstand kann im Produktionsnetz stehen und ein echter Mischer über einen Tunnel auf127.0.0.1liegen, und ein Adress-Vergleich wäre derselbe Fehlschluss wie der Namensabgleich aus ADR-002. Es wird an einer Stelle angeheftet (controlActions, nicht in den rund fünfzehn Bauplätzen der vier Protokolle, wo ein vergessener still auf „Anlage" fiele). Es fährt in jeden Beleg und steht auf dem Blatt. Und die Vorgabe ist „Anlage", weil die beiden Irrtümer nicht gleich viel kosten: ein Beleg, der fälschlich „Anlage" sagt, lässt jemanden nachsehen; einer, der fälschlich „Prüfstand" sagt, lässt ihn es lassen. -
Eine fehlende Angabe ist kein grüner Haken. Ein Adapter (
types/adapter.ts, B-46) sitzt im Signalweg und entscheidet, ob eine Strecke überhaupt trägt: „USB-C auf DisplayPort" arbeitet nur an einem Anschluss mit DisplayPort-Alternate-Mode, und zwei USB-C-Buchsen sehen gleich aus. Die Beurteilung hat deshalb drei Ausgänge und nicht zwei —passt,passt-nichtundoffen. Die dritte ist die, um die es geht: „trägt nicht" und „ist nicht erklärt" sehen auf dem Blatt gleich aus und bedeuten das Gegenteil, das eine ist ein Befund, das andere eine fehlende Angabe. Wer sie zusammenwirft, macht aus jeder Lücke einen Fehler oder aus jeder Lücke ein OK; die zweite Richtung ist die gefährliche. Daraus folgt, wie ein halber Datensatz behandelt wird:normalisiereAdaptersetzt fehlende Felder aufunbekanntherunter, statt sie stehen zu lassen. Ungeheilt wärespec.richtung === 'unbekannt'schlichtfalse, die Beurteilung fiele bis ans Ende durch — aufpasst—, und ein fehlendes Feld ergäbe genau den grünen Haken, den diese Invariante verbietet. Und keine dieser Angaben wird aus den Steckertypen abgeleitet: aus „USB-C auf DisplayPort" folgt nicht, dass der Adapter einweg ist, aus „HDMI auf HDMI" nicht, dass er 2.1 durchlässt. Das ist derselbe Fehlschluss wie der Namensabgleich aus ADR-002, nur mit Steckern statt Namen. Standards werden aus demselben Grund nur innerhalb ihrer Familie verglichen; „ist HDMI-2.0 mehr als DP-1.4?" hat keine Antwort, die stimmt, und die erfundene stünde danach in einem Befund. -
Eine Farbnorm wird gewählt, nicht mitgeliefert. Powerlock zieht man je Leiter einzeln (
types/conductor.ts, B-45): fünf Leitungen bilden einen 400-A-Anschluss, und welcher Leiter welche ist, steht in seiner Farbe. Die Farbe ist deshalb keine Kosmetik — ein vertauschter Aussenleiter dreht ein Drehfeld, ein als N gezogener ist eine Gefahr —, und genau darum istEINGEBAUTE_FARBNORMENleer. Die deutsche Neuinstallation, die ältere Farbgebung und die nordamerikanische Zuordnung sind drei verschiedene Sätze; welcher für eine Anlage gilt, steht nicht im Programm. Eine geratene Vorgabe wäre schlimmer als keine: sie sähe aus wie eine geprüfte Angabe, sie färbte jede Ader, und die Prüfung bestätigte sie anschliessend gegen sich selbst. Jede Norm trägt ihreherkunftim Klartext, und eine ohne wird beim Laden verworfen statt mit leerem Feld gezeigt (dieselbe Regel wie bei den Protokoll-Vorlagen, Invariante 18). Das Soll am Anschluss ist der zweite Teil und der eigentliche Zweck: ohne die Angabe, welche Leiter er haben muss, könnte die Prüfung nur zählen, was da ist, und nie merken, dass die vierte von fünf Leitungen fehlt. Genau dieser Fehler muss auffallen — deshalb ist eine fehlende Ader einerrorund eine fehlende Norm eininfo: wer beide gleich zeigt, lässt die erste in der zweiten untergehen. Und der Anschluss ist NICHTmulticoreName: der sagt „zähle diese Kabel als ein Stück", nur der Anschluss sagt „er muss diese Leiter haben". -
Eine EDID wird erklärt, nicht entziffert. Ein Senkenprofil (
types/displayCapability.ts, B-47) sagt, welche Formate ein Gerät annimmt — in welchen Farbtiefen, Farbräumen und Dynamik-Fassungen. Es ersetzt nicht die Aushandlung am Kabel; es beantwortet die Frage, die man vorher stellt: kommt das Bild dort an, das ich schicken will? Ausresolutionfolgt es nicht — zwei Monitore mit „3840x2160" können verschiedene Bildwiederholraten und HDR-Fassungen annehmen —, und aus einer EDID-Datei wird es hier nicht gelesen. Eine ausgelesene EDID ist eine 128-Byte-Struktur mit Erweiterungsblöcken, deren Feldbedeutungen in einer Spezifikation stehen, die aus dieser Umgebung nicht erreichbar ist. Sie aus dem Gedächtnis zu entziffern wäre schlimmer als ein nachgebautes Protokoll (Invariante 18): ein falsch gelesenes Byte ergibt keine Fehlermeldung, sondern eine plausible Zahl. Ein Gerät bekäme „nimmt 2160p60 an", weil ein Offset um eins daneben lag. Eine leere Achse heisst „dazu ist nichts erklärt" und führt zuoffen, nie zu einem stillen „na klar, 8 Bit RGB SDR". Und der Check springt nur an, wo jemand etwas erklärt hat — ein Namensabgleich auf die Kategorie („Monitor") stand kurz drin und ist wieder heraus: er wäre eine Aussage über die Schreibweise der Kategorie und nicht über das Gerät (ADR-002). -
Das Raster ist EINE Zahl.
uiStore.gridSize— im Menü unter Einstellungen → Bearbeiten → Raster einstellbar — ist die einzige Schrittweite der Fläche.lib/raster.tsleitet daraus alles ab: die Kopfhöhe der Gerätekarte, die Port-Reihe, das Innenpolster, die Vorgabebreite und das Zellmaß des Wegfinders. Niemand schreibt eine dieser Zahlen mehr hin.Vorher waren es drei Rechnungen für dieselbe Frage: die eingestellte Rastergröße, die 11er-Vielfachen in
EQUIPMENT_LAYOUTundCELL_SIZE = 20im A*. 20 ist kein Vielfaches von 11 — die Buchsen lagen also per Konstruktion zwischen zwei Gitterpunkten des Wegfinders, und der gezeichnete Weg holte den Rest als Stufe kurz vor der Buchse nach (Nutzer-Meldung 2026-09-12: „die Kabel gehen manchmal noch etwas unterhalb von dem Ziel-Port und dann wieder hoch"; in engen Szenen als Haken).Zwei Regeln tragen die Ausrichtung, beide in
raster.tsbegründet:- Das Zellmaß teilt die Rastergröße (es ist sie). Ein größeres Maß — auch ein Vielfaches wie 2 g — lässt jede zweite Port-Reihe wieder dazwischenfallen.
- Die Port-Reihe ist ein GERADES Vielfaches der Rastergröße, weil die Buchse in ihrer Mitte sitzt.
Die Mindestmaße (44 / 66 / 22 / 11 / 220 px) bleiben als Lesbarkeits- grenzen stehen und werden aufs nächste Vielfache gehoben; bei der Vorgabe 11 px ergibt das exakt die alten Zahlen. Die Grenzen
RASTER_MIN = 6undRASTER_MAX = 60sind gemessen, nicht geschätzt: eine Zelle je Rasterschritt heißt quadratisch wachsende Suchfläche, und bei 2 px braucht ein Plan mit 300 Kabeln rund sieben Sekunden (Tabelle inraster.ts). Bei der Vorgabe ist das eine Raster schneller als die alten festen 20 px — 0,45 gegen 0,86 ms je Weg —, weil ein feineres Gitter geradere Wege zulässt.Wer das Zellmaß wieder von der Rastergröße löst, fällt in
tests/rasterAlsEineZahl.test.tsundtests/anfahrtAmPort.test.ts. -
Der Breakout gehört der Buchse, nicht dem Kabel (
types/fiber.ts, #885). Eine opticalCON QUAD führt vier Fasern, ob jemand sie patcht oder nicht; ein Kabel belegt davon eine. Deshalb steht die Faser-Liste amPort(port.fasern) und die Faser-NUMMER am Kabelende (faserVon/faserNach, nebenterminationFrom/terminationTo).Die naheliegende Alternative — vier Kabel mit gemeinsamem
multicoreName— ist der Notbehelf, den heute jeder baut, und sie verliert den äusseren Steckverbinder: der Plan zeigt vier LC-Strippen und verschweigt, dass sie durch eine Buchse gehen. Genau daran hängt, ob das Kabel passt und wieviele Stecker man braucht. Ausserdem hinge die Zahl der Kabel im Plan dann an der Zahl der gepatchten Fasern: eine QUAD mit einem Duplex wäre zwei Kabel und ein Loch, und niemand könnte sagen, ob das Loch geplant oder vergessen ist.Eine Polaritäts-Methode wird gewählt, nicht mitgeliefert — dieselbe Regel wie bei den Farbnormen (Invariante 22) und aus demselben Grund: TIA-568 kennt die Methoden A, B und C, und sie unterscheiden sich darin, WO gekreuzt wird.
EINGEBAUTE_POLARITAETSNORMENist deshalb leer, jede Methode trägt ihreherkunft, und ohne gewählte Methode meldet der Plan-Check die Richtung als ungeprüft statt zu schweigen.unbestimmtist der dritte Zustand der Faserrolle und kein Notausgang: er ist der Zustand jedes Datenblatts, das die Richtung nicht nennt. Ihn alstxzu führen hiesse, gegen eine erfundene Angabe zu prüfen. Gemessen intests/fasern.test.tsundtests/drawingChecksFasern.test.ts; Letzterer hält auch fest, dass Prüfung 17b einen Breakout nicht mehr für einen Steckertyp-Fehler hält. -
Die Vorschau IST der Export (
types/bericht.ts, #880). Der Berichts-Editor formt eineCsvTable— Spalten, Gruppierung, Sortierung, Filter — und Bildschirm, CSV und Papier lesen dasselbe Ergebnis vonwendeForm(...). Eine Vorschau, die den Export nachbaut, stimmt am ersten Tag und driftet danach; das vierte Kriterium aus #880 ist deshalb keine Absprache zwischen zwei Stellen, sondern eine Eigenschaft des Aufbaus.Gearbeitet wird auf
CsvTableund nicht auf einem neuen Modell: neun Listen liefern sie bereits (lib/berichtsQuellen.ts, die eine Registry — sie lag vorher inPacketSection.tsx, und eine zweite Abschrift wäre beim nächsten Blatt auseinandergelaufen). Ein Modell darüber wäre eine zweite Beschreibung derselben Tabelle, und gedruckt würde weiter die erste.Die Spalte wird über ihren Kopftext angesprochen und nicht über einen Index: ein Index verrutscht, sobald eine Liste eine Spalte dazwischen bekommt, und die Vorlage von gestern blendet danach die falsche aus. Den Preis trägt
heileForm: Unbekanntes fällt aus der Vorlage, Neues kommt sichtbar dazu. Gemessen intests/berichtForm.test.ts. -
Die Frontplatte legt kein zweites Positionsfeld an (
types/frontplatte.ts, #879). Gemessen, bevor gebaut wurde:equipment.widthMm/heightMm(v7.9.80) sind das Mass der Platte,port.panelPosX/Y(#170) die Lage jedes Steckers,ConnectorSymbol(#472) die Zeichnung. Neu ist nur die Aussage, DASS ein Gerät eine Platte ist (equipment.frontplatte), samt Art und Streifenhöhe.Daraus folgt das vierte Kriterium aus #879 von selbst: wer im Platten-Editor zieht, verschiebt den Punkt in der Rack-Ansicht und in der 3D-Sicht mit — es ist dasselbe Feld und keine Synchronisierung.
Der Ausschnitt wird eingetragen, nie geraten. Ein D-Loch misst 24 mm, eine BNC-Durchführung je nach Bauform 10 bis 12,7 mm; welche gilt, steht im Dokument des Herstellers.
ausschnittMmist deshalb optional, und ohne ihn prüftplattenBefundenicht auf Überschneidung — und sagt das: eine Platte ohne Ausschnittmasse ist nicht kollisionsfrei, sie ist ungeprüft. Dieselbe Regel auf dem Papier: ohne Mass zeichnetfrontplattenBlattein Kreuz und keinen geratenen Kreis.Der Editor liegt in
components/Panel/und nicht incomponents/Rack/: dort hängt die Three.js-Grenze, und eine Anschlussdose soll kein 1,2-MB- Bundle nachladen. Gemessen intests/frontplatte.test.ts.
Diese Themen sind diskutiert, aber noch nicht entschieden / umgesetzt.
Implementiert. projectStore.ts von 2178 LOC auf ~1146 reduziert durch
26 Slices unter store/slices/. Siehe §3.1.1.
EquipmentProperties→ 25 Sub-Sections ✓SettingsDialog→ 8 Tabs ✓- Noch offen:
LibraryPanel,RackBuilderDialog,CanvasArea,RentmanImportDialog.
Heute: Erweiterungen brauchen Code-Fork. Ein schmaler Plugin-Slot für Reports und Library-Loader wäre eine günstige Investition gegen Bus-Faktor-1.
Drei Optionen mit sehr unterschiedlichem Aufwand:
- Multi-Mobile-View: bestehende Mobile-Share-View für mehrere Clients ausbauen, Editor bleibt single-user. 1–2 Tage, niedrige Risiken.
- Yjs-CRDT P2P im LAN:
yjs+y-webrtc, Projekt-Daten alsY.Doc, Sync zwischen Electron-Instanzen. ~1–2 Wochen, Store-Schema muss CRDT-tauglich werden. - Cloud-Backend mit
y-websocket: Yjs-Server, Auth, Permissions. Mehrere Wochen plus dauerhafte Betriebskosten.
Slice-Architektur (#308) ist die Vorbereitung — jeder Slice macht immutable Updates, Yjs-Mapping wäre ein Adapter.
Stand (#413, #471 — weitgehend umgesetzt): Die Yjs-CRDT-P2P-Variante
ist real implementiert, nicht mehr nur Fundament. Vorhanden unter
src/renderer/lib/crdt/:
projectCrdt.ts— Projekt-Collections alsY.Doc, Konvergenz bewiesen (npm run test:crdt,scripts/crdt-convergence-check.mjs).storeBinding.ts— Live-Bindung projectStore ⇄Y.Doc.webrtcProvider.ts+broadcastTransport.ts+syncTransport.ts/syncManager.ts— Transport (y-webrtc, dep^10.3.0) inkl. Broadcast-Fallback.presence.ts— Presence/Awareness;collab.ts+collabStore.ts+components/Sync/CollabPanel.tsx+lib/collabInvite.ts— Session, UI, Einladungs-Code.- Main-Seite:
src/main/signalingServer.ts,ipc/signalingIpc.ts(LAN-Signaling-Relay),ipc/collabDiscoveryIpc.ts(mDNS-Peer-Discovery).
Noch offen / Reifegrad: vollständige CRDT-Abdeckung aller Collections
im Live-Betrieb, robuste Undo-Integration über mehrere Clients und ein
optionales Cloud-Backend (y-websocket, Auth/Permissions) bleiben offen.
vitest ist eingerichtet (npm test / npm run test:watch); dazu kommen
gezielte Node-Checks (npm run test:crdt, npm run test:signaling), ein
UI-Smoke-Skript (npm run ui:smoke) und ein headless Drag-/Interaktions-Test
(npm run test:drag, treibt den Renderer via Playwright). Bei ~271.5k LOC
bleibt der Ausbau der Abdeckung wichtig — empfohlene Schwerpunkte:
- Snapshot-Tests auf
healProjectPositionsmit echten Beispiel-Projekt-JSONs. - Property-Tests auf
projectHistory(Undo-Redo-Invarianten). - Smoke-Tests auf IPC-Channels (Mock-
fs).
Read-only Web-Renderer als eigener Vite-Entry viewer.html
(src/viewer/ViewerApp.tsx, in vite.config.ts als viewer-Input) für
Reviewer ohne Desktop-App — rendert ein geladenes .cpviewer/.json
standalone, keine Edits. Wird über .github/workflows/pages.yml
(npm run build:renderer → dist/renderer) auf GitHub Pages deployt.
| Aufgabe | Hierhin |
|---|---|
| Neue IPC-Funktion | src/main/ipc/<domain>Ipc.ts + src/main/preload.cts |
| Neuer Service (HTTP, DB, Native) | src/main/services/ |
| File-I/O-Helper | src/main/util/ |
| Neuer Renderer-State-Concern | eigener Slice in src/renderer/store/slices/ |
| Neuer Canvas-Knoten/-Edge | src/renderer/components/Canvas/ |
| Neue 3D-Visualisierung | src/renderer/components/Rack/ (Three.js-Grenze) |
| Neuer Domänen-Typ | src/renderer/types/<thema>.ts |
| Neue Schema-Migration | healProjectPositions in projectStore.ts |
| Neues Export-Format | src/renderer/components/Export/ |
| Neue Berechnung (Length, Power, ...) | src/renderer/lib/ |
| Neue UI-Texte | t('domain.key', 'Deutsche Fallback') + EN-Entry in lib/i18n.ts |
| Neue Property-Section | src/renderer/components/Properties/sections/ + Eintrag in EquipmentProperties.tsx Reihenfolge |
| Neuer Settings-Tab | src/renderer/components/Settings/tabs/ + Eintrag in SettingsDialog.tsx Sidebar |
| Neues Geheimnis (Token, Key) | credentialsService.ts (keytar) + eigener IPC-Namensraum — niemals ein Feld im Projekt |
| Neues gestempeltes Dokument | Tabelle in lib/, Eintrag in DOCUMENT_STANDS (documentRegistry.ts), Export via csvFromTable(..., stamp, docId) |
| Neue Gerätekonfiguration (Datei, die an ein fremdes Gerät geht) | lib/deviceConfigExport.ts#exportDeviceConfig — nicht downloadBlob direkt: sonst geht die Datei ohne Herkunfts-Blatt raus (Bedarf 43) |
| Alle Adressen eines Geräts lesen | lib/networkInterfaces.ts#deviceInterfaces — nicht item.ipAddress (das ist nur Schnittstelle 0) |
| CSV lesen | lib/csvParse.ts#parseCsv — die eine Stelle; ein zweiter Parser antwortet beim ersten Semikolon im Feld anders |