Compare commits

...

23 commits
v0.0.4 ... main

Author SHA1 Message Date
Dieter Lang
8b7d081960 README und Testkonfiguration für lokalen Simulationsaufbau aktualisieren 2026-08-25 18:13:42 +02:00
da212e943b Version 0.0.11 - Windows-Client mit funktionierender Fyne-GUI und Rotor-Startcode 2026-08-25 17:16:45 +02:00
3a85b17422 Windows-Client mit funktionierender Fyne-GUI und Rotor-Startcode 2026-08-25 17:12:35 +02:00
Dieter Lang
c9e3ba052a Linux-PTY-Rückweg und plattformabhängige Porttests ergänzen 2026-08-22 16:24:15 +02:00
d890aba7b4 Windows-Testwerkzeuge für virtuelle COM-Ports ergänzen 2026-08-22 15:22:40 +02:00
0eccd003f0 Startbytes für virtuelle Geräteverbindungen ergänzen 2026-08-22 15:21:50 +02:00
f2428a2007 PTY-Testwerkzeug und Server-Testkonfiguration ergänzen 2026-08-22 15:19:00 +02:00
21d622e040 Windows-Testskripte für Rotorverbindung ergänzen 2026-08-21 14:28:54 +02:00
Dieter Lang
d9a70b9225 Version 0.0.10 dokumentieren 2026-08-21 12:24:02 +02:00
Dieter Lang
61bd03b863 Linux-Portverwaltung für Windows-Erweiterung korrigieren 2026-08-21 12:20:49 +02:00
a6cd04ece4 Version 0.0.9 dokumentieren 2026-08-21 11:30:41 +02:00
39a1fea75d technischen Windows-Client erfolgreich mit Rot2Prog getestet 2026-08-21 11:02:25 +02:00
Dieter Lang
81b06a16a0 Version 0.0.8 - Virtuelle Ports validieren und GUI verbessern 2026-08-12 15:57:46 +02:00
Dieter Lang
90189ab4dc Gerätezuordnung in GUI verwalten und speichern 2026-08-12 12:42:24 +02:00
Dieter Lang
5e595fe460 Close assignment window with main window 2026-08-11 19:20:58 +02:00
Dieter Lang
8d98e85453 Add working hardware connectivity and SPID rotor support 2026-08-11 19:07:20 +02:00
Dieter Lang
1621ed4330 Hardwarefehler an Client zurückmelden 2026-08-11 15:19:36 +02:00
Dieter Lang
a33546fbed Geräteerkennung ohne Datenverbindungen 2026-08-11 13:26:05 +02:00
Dieter Lang
084ca66c7b Gerätezuordnungen im Client anzeigen 2026-08-11 12:51:17 +02:00
Dieter Lang
80e4196e16 Respect configured virtual port assignments 2026-08-11 11:29:49 +02:00
Dieter Lang
f3610b989c Add client GUI and reconnect 2026-08-11 10:59:46 +02:00
Dieter Lang
9d5425975b Add client runtime and serial bridge 2026-08-10 17:59:35 +02:00
Dieter Lang
1d3239da71 Add configurable local virtual serial ports 2026-08-10 16:20:28 +02:00
62 changed files with 10200 additions and 197 deletions

View file

@ -2,6 +2,254 @@
Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert.
## [0.0.11] - 2026-08-25
### Hinzugefügt
- Windows-Client erfolgreich mit Fyne-GUI gestartet.
- Übertragung der konfigurierten `startup_bytes` an das zugeordnete
Remote-Gerät beim Verbindungsaufbau getestet.
### Geändert
- Windows-spezifische Behandlung virtueller serieller Ports angepasst.
- Öffnen und Verwaltung virtueller serieller Schnittstellen unter Windows
überarbeitet.
### Getestet
- Minimaler Fyne-Test unter Windows erfolgreich gestartet.
- `rs2322tcp-client` unter Windows erfolgreich mit Fyne-GUI gestartet.
- Verbindung des Windows-Clients zum `rs2322tcp-server` erfolgreich aufgebaut.
- Rotor-Startcode erfolgreich vom Windows-Client bis zum Server übertragen.
## [0.0.10] - 2026-08-21
### Behoben
- Linux-Portverwaltung nach der Windows-Client-Erweiterung korrigiert.
- Doppelte Linux-Definitionen der virtuellen Portverwaltung entfernt.
- Plattformübergreifende Trennung der virtuellen Port-Erzeugung korrigiert.
- Dauerhafte Installations-Symlinks für virtuelle Linux-Ports bleiben beim Schließen eines Ports erhalten.
- Gesamter Go-Testlauf unter Linux erfolgreich.
## [0.0.9] - 2026-08-21
### Added
- Windows-Unterstützung für den rs2322tcp-Client
- Plattformabhängige Verwaltung virtueller serieller Schnittstellen
- Windows-COM-Port-Unterstützung über `go.bug.st/serial`
- Unterstützung des virtuellen Windows-Portbereichs COM100 bis COM199
- Windows-spezifische Portvalidierung und Portpfadverwaltung
- Technischer Windows-Client unter `cmd/rs2322tcp-client-test`
- Plattformabhängige Implementierungen für das Öffnen und Verwalten
virtueller serieller Schnittstellen
- Windows-spezifische Implementierung von `VirtualSerial`
### Changed
- Virtuelle Portverwaltung für Linux und Windows auf eine gemeinsame
Schnittstelle umgestellt
- Clientseitige Portvalidierung für die unterschiedlichen Plattformen
erweitert
- Client-Konfiguration für den Windows-Betrieb angepasst
### Tests
- Windows 11 / amd64 erfolgreich getestet
- Windows-COM-Port COM100 über com0com erfolgreich geöffnet
- com0com-Portpaar `COM10 ↔ COM100` erfolgreich getestet
- Verbindung des Windows-Clients über Tailscale zum Raspberry-Pi-Server
erfolgreich getestet
- Bidirektionale Datenübertragung zwischen Windows-Client und
Raspberry-Pi-Server erfolgreich getestet
- Bidirektionale Datenübertragung mit einem realen SPID Rot2Prog
erfolgreich getestet
- Rot2Prog mit `P5 = 59` (SPID) und Function Mode `A` (Auto) erfolgreich
betrieben
- Tatsächliche Antwort des Rot2Prog erfolgreich über den vollständigen
Datenweg übertragen:
```text
57 07 01 06 00 01 03 06 02 00 01 20
```
- `go test ./internal/client` erfolgreich
### Status
Der Windows-Client kann über einen virtuellen Windows-COM-Port mit dem
rs2322tcp-Server auf einem Raspberry Pi kommunizieren.
Der vollständige Datenweg wurde mit einem realen SPID Rot2Prog
bidirektional erfolgreich getestet:
```text
COM10
com0com
COM100
rs2322tcp-client (Windows)
TCP / Tailscale
rs2322tcp-server (Raspberry Pi)
RS232 / SPID Rot2Prog
```
Der technische Windows-Client bleibt als separates Testprogramm erhalten.
Die grafische Fyne-Oberfläche des Windows-Clients ist noch nicht
vollständig abgeschlossen.
## [0.0.8] - 2026-08-12
### Added
- Prüfung der lokal vorhandenen virtuellen Ports beim Client-Start
- Abgleich der vorhandenen symbolischen Links mit den in `client.json`
definierten virtuellen Schnittstellen
- Erkennung von in `client.json` definierten, aber lokal nicht vorhandenen
virtuellen Ports
- Erkennung von lokal vorhandenen, aber nicht in `client.json` definierten
virtuellen Ports
- Verständlicher Fehlerdialog bei einer abweichenden virtuellen
Portkonfiguration
- Beenden des Clients nach Bestätigung einer fehlerhaften
Portkonfiguration
### Changed
- Virtuelle Ports werden entsprechend der in `client.json` festgelegten
Portnummern gezielt geöffnet
- Die Verwaltung virtueller Ports arbeitet weiterhin ohne Root-Rechte
- Der Fehlerdialog verwendet einen einzelnen OK-Button
- Allgemeine Schriftgröße der grafischen Benutzeroberfläche auf 22 erhöht
- Hauptfenster des Clients auf 800 × 600 Pixel vergrößert
### Tests
- Prüfung vorhandener und konfigurierter virtueller Ports erfolgreich getestet
- Fehlender virtueller Port erfolgreich erkannt und im Fehlerdialog angezeigt
- Zusätzlicher, nicht konfigurierter virtueller Port erfolgreich erkannt
- Clientstart nach Korrektur der virtuellen Portkonfiguration erfolgreich getestet
- Gerätezuordnung nach Neustart des Clients erfolgreich aus `client.json`
geladen
- `go test ./...` erfolgreich
- `go test -race ./internal/client` erfolgreich
- `go test -race ./internal/gui` erfolgreich
- `git diff --check` erfolgreich
## [0.0.7] - 2026-08-12
### Added
- Pflege der Gerätezuordnung über die grafische Benutzeroberfläche
- Zuordnung der vom Server angebotenen Geräte zu den konfigurierten
virtuellen seriellen Schnittstellen
- Unterstützung des Zustands „nicht verbunden“
- Freie virtuelle Schnittstellen werden bei der Gerätezuordnung automatisch
neu angeboten
- Verhinderung der gleichzeitigen Zuordnung einer virtuellen Schnittstelle
zu mehreren Geräten
- Bereits zugeordnete Geräte können nur auf ihre aktuelle Schnittstelle oder
„nicht verbunden“ gesetzt werden
- Ungespeicherte Änderungen werden beim Schließen des Hauptfensters erkannt
und dem Anwender angezeigt
- Änderungen werden beim Schließen des Gerätezuordnungsfensters in der
Client-Konfiguration gespeichert
- Virtuelle Schnittstellen bleiben auch ohne Gerätezuordnung in der
Client-Konfiguration erhalten
- Ein leerer Wert für `remote_device` kennzeichnet eine freie virtuelle
Schnittstelle
- `Application.SaveConfig()` zum zentralen Speichern der Client-Konfiguration
### Changed
- Validierung der Client-Konfiguration erlaubt nun virtuelle Schnittstellen
ohne aktuelle Gerätezuordnung
- Die Gerätezuordnung arbeitet auf Basis der vorhandenen virtuellen
Schnittstellen und verändert bei einer Trennung nur deren
`remote_device`-Zuordnung
### Tests
- Gerätezuordnung mit mehreren Server-Geräten praktisch getestet
- Freigabe einer virtuellen Schnittstelle und anschließende erneute Auswahl
erfolgreich getestet
- Speicherung einer Gerätezuordnung über die GUI erfolgreich getestet
- Speicherung einer getrennten Gerätezuordnung mit leerem
`remote_device` erfolgreich getestet
- `go test ./internal/config` erfolgreich
- `go test ./internal/gui` erfolgreich
- `go test -race ./internal/gui` erfolgreich
- `git diff --check` erfolgreich
## [0.0.6] - 2026-08-10
### Added
- Clientseitige Runtime zur Verwaltung mehrerer virtueller serieller Ports
- Verbindung konfigurierter virtueller Ports mit den vom Server bereitgestellten
Remote-Geräten
- Clientseitige TCP-Data-Connections zu den dynamischen Data-Ports des Servers
- Bidirektionale Datenübertragung über eine `Bridge`
- Verwaltung des Lebenszyklus von virtuellen Ports, Bridges und Client-Verbindung
- Integrationstest für den vollständigen Datenweg zwischen virtuellem seriellen
Port und Server
- Tests für Verbindungsaufbau, Gerätezuordnung und Runtime-Lebenszyklus
### Tests
- `go test ./...` erfolgreich
- `go test -race ./...` erfolgreich
- Bidirektionale Übertragung über Runtime, Bridge, TCP und PTY erfolgreich
- Race Conditions in Client-Control-Verbindung, PTY und virtuellen Ports behoben
## [0.0.5] - 2026-08-10
### Added
- Konfigurierbarer Bereich für lokale virtuelle serielle Schnittstellen
- Standardbereich für virtuelle Ports von `/dev/ttyUSB100` bis
`/dev/ttyUSB199`
- Abweichender Portbereich kann über `virtual_port_range` in der
Client-Konfiguration festgelegt werden
- Rückwärtskompatibilität für bestehende Client-Konfigurationen ohne
`virtual_port_range`
- Verwaltung virtueller Ports über `VirtualPortManager`
- Automatische Auswahl des nächsten freien virtuellen Ports
- Freigabe reservierter Ports bei deren Schließen
- Linux-spezifische Verwaltung der virtuellen Port-Symlinks unter
`~/.rs2322tcp/virtual/`
- Verknüpfung der virtuellen `/dev/ttyUSBxxx`-Schnittstellen mit den
vom Client erzeugten PTYs
- Einführung von `VirtualPort` und `ManagedVirtualPort`
- Umfangreiche Tests für Portauswahl, Portfreigabe, Symlink-Verwaltung
und Linux-PTY-Integration
### Tests
- `go test ./...` erfolgreich
- `go test -race ./...` erfolgreich
- Bidirektionale Kommunikation über `/dev/ttyUSB100` erfolgreich getestet
- Externe Software kann ausschließlich `/dev/ttyUSB100` verwenden
- Übertragung vom virtuellen `/dev/ttyUSB100` zum Client erfolgreich getestet
- Übertragung vom Client zum virtuellen `/dev/ttyUSB100` erfolgreich getestet
- Zweistufige Symlink-Kette von `/dev/ttyUSB100` über
`~/.rs2322tcp/virtual/ttyUSB100` bis zum PTY erfolgreich getestet
---
## [0.0.4] - 2026-08-10
### Added

704
README.md
View file

@ -48,9 +48,9 @@ rs2322tcp-client │
Funkgerät Rotor
```
## Aktueller Server-Datenpfad
## Aktueller Datenpfad
Die serverseitige TCP-/RS232-Verbindung ist implementiert:
Der serverseitige TCP-/RS232-Datenpfad ist implementiert.
```text
TCP-Control
@ -85,32 +85,88 @@ TCP ───────────────► RS232
TCP ◄────────────── RS232
```
Auch der Client-Datenpfad ist implementiert:
```text
Virtueller serieller Port
rs2322tcp-client
│ TCP-Control
Session
│ TCP-Data
rs2322tcp-server
reale serielle
Schnittstelle
Gerät
```
Der Client kann mehrere konfigurierte virtuelle serielle Schnittstellen
gleichzeitig verwalten.
## Client
Der Client soll gleichberechtigt unter folgenden Betriebssystemen
eingesetzt werden können:
- Windows
- Linux
- Windows
Die plattformspezifische Bereitstellung der virtuellen seriellen
Schnittstelle wird vom gemeinsamen Client-Kern getrennt.
Die gemeinsame Client-Logik ist von der plattformspezifischen
Bereitstellung der virtuellen seriellen Schnittstelle getrennt.
Unter Linux ist die virtuelle serielle Schnittstelle inzwischen über
einen direkt aus Go erzeugten PTY implementiert. Die Implementierung
verwendet `/dev/ptmx` und stellt den zugehörigen PTY-Slave unter
`/dev/pts/N` bereit.
Der Client besteht im Wesentlichen aus:
- Control-Verbindung zum Server
- Verwaltung der vom Server angebotenen Geräte
- Runtime zur Verwaltung mehrerer virtueller Ports
- virtuellen seriellen Schnittstellen
- TCP-Data-Connections
- bidirektionalen Bridges zwischen virtuellem Port und TCP-Verbindung
- grafischer Benutzeroberfläche
### Linux
Unter Linux ist die virtuelle serielle Schnittstelle über einen direkt
aus Go erzeugten PTY implementiert.
Die Implementierung verwendet `/dev/ptmx` und stellt den zugehörigen
PTY-Slave unter `/dev/pts/N` bereit.
Die PTY-Schnittstelle wird im Raw Mode betrieben. Dadurch werden die
übertragenen Daten als transparenter Byte-Strom behandelt.
Die allgemeine Client-Logik verwendet dafür die Abstraktion
`VirtualSerial`. Die Linux-spezifische Implementierung ist von dieser
Abstraktion getrennt.
`VirtualSerial`.
Die Linux-spezifische Implementierung ist von dieser Abstraktion
getrennt.
Für die PTY-Funktion ist keine externe Laufzeitabhängigkeit wie `socat`
erforderlich.
### Windows
Die gemeinsame Client-Architektur ist bereits auf eine
plattformabhängige Implementierung der virtuellen seriellen
Schnittstelle vorbereitet.
Unter Windows wird dieselbe gemeinsame Client- und Konfigurationslogik
verwendet. Für Tests können vorhandene virtuelle COM-Port-Verbindungen
verwendet werden.
Mit Version `v0.0.11` wurde der Windows-Client mit funktionierender
Fyne-GUI sowie der automatischen Übertragung der konfigurierten Rotor-
Startbytes praktisch getestet.
## Server
Der Server ist zunächst für den Betrieb auf einem Raspberry Pi 5
@ -159,30 +215,174 @@ untersucht werden.
Client und Server erhalten jeweils eine eigene JSON-Konfiguration.
Über die Konfiguration werden unter anderem festgelegt:
### Client-Konfiguration
- Netzwerkparameter
- serielle Schnittstelle
- Baudrate
- Datenbits
- Parität
- Stopbits
- Bezeichnung des Gerätes
- weitere für die jeweilige Schnittstelle erforderliche Parameter
Über die Client-Konfiguration werden unter anderem festgelegt:
Die konkrete Konfigurationsstruktur befindet sich in der Entwicklung.
- Server-Adresse
- Server-Port
- reservierter Bereich der virtuellen seriellen Schnittstellen
- Zuordnung virtueller Schnittstellen zu Remote-Geräten
## Diagnose und Logging
Beispiel:
Für Debugging und Diagnose soll eine Protokollierung des übertragenen
Datenverkehrs möglich sein.
```json
{
"server": {
"address": "127.0.0.1",
"port": 5000
},
"virtual_port_range": {
"first": 100,
"last": 199
},
"virtual_ports": [
{
"port": "/dev/ttyUSB100",
"remote_device": "radio"
},
{
"port": "/dev/ttyUSB101",
"remote_device": "rotor"
}
]
}
```
Insbesondere soll eine Darstellung der übertragenen Bytes möglich sein,
um Probleme bei der Kommunikation zwischen Hersteller-Software und Gerät
analysieren zu können.
Der konfigurierte Bereich
Während der Entwicklung kann `socat` als zusätzliches Werkzeug für Tests
und Diagnose eingesetzt werden.
```json
"virtual_port_range": {
"first": 100,
"last": 199
}
```
definiert den für `rs2322tcp` vorgesehenen Bereich virtueller
Schnittstellen.
Die konkrete Zuordnung wird über `virtual_ports` festgelegt.
Beispielsweise:
```text
/dev/ttyUSB100 -> radio
/dev/ttyUSB101 -> rotor
```
Ein leerer Wert für `remote_device` kennzeichnet eine freie virtuelle
Schnittstelle.
Virtuelle Schnittstellen bleiben dadurch auch ohne aktuelle
Gerätezuordnung in der Client-Konfiguration erhalten.
### Gerätezuordnung
Die vom Server angebotenen Geräte können über die grafische
Benutzeroberfläche den konfigurierten virtuellen Schnittstellen
zugeordnet werden.
Dabei gelten folgende Regeln:
- Eine virtuelle Schnittstelle kann nur einem Gerät zugeordnet werden.
- Ein Gerät kann nicht gleichzeitig mehreren virtuellen Schnittstellen
zugeordnet werden.
- Eine bereits belegte Schnittstelle kann auf ihre aktuelle Zuordnung
oder auf „nicht verbunden“ gesetzt werden.
- Freie virtuelle Schnittstellen werden automatisch zur Auswahl
angeboten.
- Änderungen werden zunächst nur im Arbeitsspeicher gehalten.
- Beim Schließen des Gerätezuordnungsfensters werden die Änderungen in
`client.json` gespeichert.
- Wird das Hauptfenster geschlossen, während ungespeicherte Änderungen
vorhanden sind, wird der Anwender darauf hingewiesen.
## Prüfung der virtuellen seriellen Schnittstellen
Beim Start des Linux-Clients werden die in `client.json` definierten
virtuellen Schnittstellen mit den tatsächlich vorhandenen öffentlichen
symbolischen Links verglichen.
Dabei werden ausschließlich zwei Listen miteinander verglichen:
```text
vorhandene symbolische Links
definierte virtuelle Ports
```
Es werden insbesondere folgende Abweichungen erkannt:
- Ein in `client.json` definierter Port besitzt keinen entsprechenden
öffentlichen Symlink.
- Ein öffentlicher Symlink ist vorhanden, aber nicht in `client.json`
eingetragen.
Bei einer Abweichung wird der Clientstart abgebrochen und dem Anwender
ein verständlicher Hinweis angezeigt.
Der Client legt fehlende öffentliche Links nicht automatisch an.
Damit bleibt die Systemkonfiguration unter der Kontrolle des Anwenders.
Die Prüfung selbst verändert keine Systemdateien und benötigt keine
Root-Rechte.
## Manuelle Einrichtung der virtuellen seriellen Ports unter Linux
Die öffentlichen virtuellen seriellen Ports werden unter Linux einmalig
eingerichtet.
Der laufende `rs2322tcp-client` benötigt anschließend keine Root-Rechte.
Zunächst wird das Verzeichnis für die internen virtuellen Links angelegt:
```bash
mkdir -p ~/.rs2322tcp/virtual
```
Anschließend werden die öffentlichen `/dev/ttyUSBxxx`-Links einmalig
mit administrativen Rechten angelegt.
Beispiel:
```bash
sudo ln -s "$HOME/.rs2322tcp/virtual/ttyUSB100" /dev/ttyUSB100
sudo ln -s "$HOME/.rs2322tcp/virtual/ttyUSB101" /dev/ttyUSB101
```
Die Zuordnung ist:
```text
/dev/ttyUSB100 -> ~/.rs2322tcp/virtual/ttyUSB100 -> PTY
/dev/ttyUSB101 -> ~/.rs2322tcp/virtual/ttyUSB101 -> PTY
```
Die äußeren Links unter `/dev` gehören dabei `root`. Das ist beabsichtigt.
Der Client selbst läuft anschließend als normaler Benutzer.
Die internen Links unter:
```text
~/.rs2322tcp/virtual/
```
werden vom Client auf die jeweils verwendeten PTYs gesetzt.
Die Einrichtung eines neuen virtuellen Ports besteht daher aus:
1. Einen freien Port aus dem konfigurierten Bereich auswählen.
2. Den öffentlichen `/dev/ttyUSBxxx`-Symlink einmalig anlegen.
3. Den Port in `client.json` eintragen.
4. Den Client starten.
5. Die Gerätezuordnung gegebenenfalls über die GUI vornehmen.
Der Client prüft beim Start, ob die Konfiguration und die vorhandenen
Symlinks übereinstimmen.
Die konkrete Einrichtung ist Linux-spezifisch. Für Windows wird später
eine eigene plattformspezifische Lösung verwendet.
## Virtuelle serielle Schnittstelle unter Linux
@ -204,7 +404,9 @@ PTY Slave
/dev/pts/N
```
Die externe Hersteller-Software arbeitet mit dem PTY-Slave `/dev/pts/N`.
Der PTY-Slave wird über den vom Anwender eingerichteten virtuellen
`/dev/ttyUSBxxx`-Symlink erreichbar.
Der `rs2322tcp-client` verwendet den PTY-Master und transportiert die
Bytes über TCP zum Server.
@ -214,6 +416,382 @@ vollständigen Bytebereich von `0x00` bis `0xFF` geprüft.
Dabei wurde die bidirektionale Übertragung aller 256 möglichen Bytewerte
erfolgreich getestet.
## Vollständiger Simulationsaufbau auf einem Ubuntu-PC
Der komplette Datenweg kann für Entwicklung und Tests auf einem einzigen
Ubuntu-PC ausgeführt werden. Dazu laufen gleichzeitig drei Programme:
1. `rs2322tcp-pty-test` als simuliertes Rotorgerät
2. `rs2322tcp-server` mit `configs/server-test.json`
3. `rs2322tcp-client`
Der Datenweg ist:
```text
Testprogramm oder Hersteller-Software
/dev/ttyUSB100
rs2322tcp-client
│ TCP
rs2322tcp-server
/dev/pts/N
rs2322tcp-pty-test
simuliertes Rotorgerät
```
### 1. PTY-Simulator starten
In einem ersten Terminal:
```bash
cd ~/rs2322tcp
go run ./cmd/rs2322tcp-pty-test
```
Beispiel:
```text
rs2322tcp-pty-test
Port: /dev/pts/1
Modus: Rotor-Simulation mit Echo-Fallback
Warte auf Daten ...
```
Der ausgegebene PTY-Pfad wird im nächsten Schritt benötigt.
Der Simulator hält Master und Slave während seiner gesamten Laufzeit geöffnet.
Bei der bekannten Rotor-Anfrage wird automatisch die konfigurierte Rotor-Antwort
gesendet. Alle anderen Daten werden als Echo zurückgegeben.
### 2. Testkonfiguration des Servers anpassen
In `configs/server-test.json` wird der Rotor auf den aktuell vom Simulator
ausgegebenen PTY-Slave gesetzt.
Beispiel:
```json
{
"id": "rotor",
"name": "Antennenrotor",
"serial_port": "/dev/pts/1",
"baud_rate": 600,
"data_bits": 8,
"parity": "none",
"stop_bits": 1
}
```
Die PTY-Nummer ist nicht dauerhaft fest. Nach jedem neuen Start des
PTY-Simulators muss der tatsächlich ausgegebene `/dev/pts/N`-Pfad geprüft und
gegebenenfalls manuell in `server-test.json` eingetragen werden.
Im Echtbetrieb wird hier wieder ein fester Hardware-Port verwendet, zum Beispiel:
```text
/dev/ttyUSB0
```
Die serverseitige Zuordnung bleibt bewusst manuell und fest konfiguriert, weil
mehrere unterschiedliche Hardwaregeräte gleichzeitig am Server angeschlossen
sein können.
### 3. Server starten
In einem zweiten Terminal:
```bash
cd ~/rs2322tcp
go run ./cmd/rs2322tcp-server -config ./configs/server-test.json
```
Beispiel:
```text
rs2322tcp-server dev
Control-Server gestartet auf [::]:5000
Geräte: 3
```
### 4. Server-Monitor als Sniffer
Ist in der Server-Konfiguration
```json
"serial_monitor": true
```
gesetzt, protokolliert der Server den übertragenen Datenverkehr. Im
Simulationsaufbau werden dadurch unter anderem die Richtungen
```text
TCP RX
SERIAL TX
SERIAL RX
TCP TX
```
sichtbar.
Ein typischer Ablauf ist:
```text
TCP RX 57 00 00 00 00 00 00 00 00 00 00 1F 20
SERIAL TX 57 00 00 00 00 00 00 00 00 00 00 1F 20
SERIAL RX 57 07 01 06 00 01 03 06 02 00 01 20
TCP TX 57 07 01 06 00 01 03 06 02 00 01 20
```
Der Server arbeitet damit gleichzeitig als Monitor beziehungsweise Sniffer für
den übertragenen seriellen Datenverkehr.
### 5. Client-Konfiguration für den lokalen Test
Für den lokalen Test kann `configs/client.json` beispielsweise so aussehen:
```json
{
"server": {
"address": "127.0.0.1",
"port": 5000
},
"virtual_port_range": {
"first": 100,
"last": 199
},
"virtual_ports": [
{
"port": "/dev/ttyUSB100",
"remote_device": "rotor",
"startup_bytes": "57 00 00 00 00 00 00 00 00 00 00 1F 20"
}
]
}
```
Der öffentliche virtuelle Port `/dev/ttyUSB100` muss vorher wie im Abschnitt
zur manuellen Einrichtung der virtuellen Ports einmalig angelegt worden sein.
### 6. Client starten
In einem dritten Terminal:
```bash
cd ~/rs2322tcp
go run ./cmd/rs2322tcp-client
```
Nach erfolgreichem Verbindungsaufbau werden die konfigurierten `startup_bytes`
automatisch zum zugeordneten Rotor übertragen.
Der Server-Monitor muss dann den vollständigen Ablauf
```text
TCP RX -> SERIAL TX -> SERIAL RX -> TCP TX
```
zeigen.
Damit ist der gesamte Datenweg vom virtuellen Client-Port über TCP zum
simulierten Rotor und wieder zurück geprüft.
### 7. Virtuellen Port direkt testen
Eine Antwort kann beispielsweise mit
```bash
cat /dev/ttyUSB100 | xxd -g 1
```
beobachtet werden.
In einem zweiten Terminal kann eine Rotor-Anfrage gesendet werden:
```bash
printf '\x57\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1f\x20' > /dev/ttyUSB100
```
Der Rückweg erfolgt über denselben vollständigen Datenpfad zurück zu
`/dev/ttyUSB100`.
### Hinweis zu `/dev/pts/N`
`/dev/pts/N` wird in diesem Aufbau ausschließlich als temporärer Test-Port
für den PTY-Simulator verwendet. Die Nummer ist nicht dauerhaft und kann sich
nach einem Neustart des Simulators ändern.
Im produktiven Betrieb sind die serverseitigen Geräte dagegen echte,
fest angeschlossene Hardware-Schnittstellen, beispielsweise:
```text
/dev/ttyUSB0
/dev/ttyUSB1
/dev/ttyUSB3
```
## Diagnose und Logging
Für Debugging und Diagnose soll eine Protokollierung des übertragenen
Datenverkehrs möglich sein.
Insbesondere soll eine Darstellung der übertragenen Bytes möglich sein,
um Probleme bei der Kommunikation zwischen Hersteller-Software und Gerät
analysieren zu können.
Für die Linux-PTY-Funktion und den beschriebenen Simulationsaufbau wird
`rs2322tcp` ohne `socat` betrieben. Die benötigten PTYs werden direkt aus Go
erzeugt.
## Hardwaretest mit SPID Rot2Prog
Für den praktischen Hardwaretest wurde ein SPID Rot2Prog als
Antennenrotor über einen FT232R USB-to-RS232-Adapter angeschlossen.
Unter Linux wurde der Adapter als:
```text
/dev/ttyUSB0
```
erkannt.
Der Rot2Prog verwendet für die RS232-Kommunikation:
```text
600 Baud
8 Datenbits
keine Parität
1 Stopbit
```
also:
```text
600 8N1
```
### Rotor einschalten
Vor dem Kommunikationstest muss der SPID-Rot2Prog eingeschaltet sein.
Der Controller muss für die serielle Steuerung im SPID-/Auto-Betrieb
betrieben werden.
Der Zustand des Controllers kann über die vorhandenen Bedientasten
und die LED-Anzeigen kontrolliert werden.
### Direkter Hardwaretest
Der serielle Port kann zunächst unabhängig von `rs2322tcp` getestet
werden:
```bash
stty -F /dev/ttyUSB0 600 cs8 -cstopb -parenb raw -echo
```
Kontrolle:
```bash
stty -F /dev/ttyUSB0
```
Anschließend kann die serielle Antwort des Controllers beobachtet werden:
```bash
cat /dev/ttyUSB0 | xxd -g 1
```
Eine Statusabfrage kann beispielsweise mit folgendem Paket gesendet
werden:
```bash
printf '\x57\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1f\x20' > /dev/ttyUSB0
```
Bei erfolgreicher Kommunikation antwortet der Rot2Prog mit einem
12-Byte-Datenpaket.
### Test über rs2322tcp
Nach erfolgreichem direkten Hardwaretest wird der reale serielle Port
im Server konfiguriert:
```text
Rotor:
/dev/ttyUSB0
600 Baud
8 Datenbits
keine Parität
1 Stopbit
```
Der Client stellt dem Anwender dafür beispielsweise den virtuellen Port:
```text
/dev/ttyUSB101
```
zur Verfügung.
Die gleiche Statusabfrage kann anschließend über den vollständigen
rs2322tcp-Datenpfad gesendet werden:
```bash
printf '\x57\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1f\x20' > /dev/ttyUSB101
```
Der Datenweg ist dann:
```text
/dev/ttyUSB101
rs2322tcp-client
│ TCP
rs2322tcp-server
/dev/ttyUSB0
│ 600 Baud / 8N1
SPID Rot2Prog
│ Antwort
rs2322tcp-server
│ TCP
rs2322tcp-client
/dev/ttyUSB101
```
Damit kann die Antwort des Rotors wieder über den virtuellen seriellen
Port gelesen werden.
Ein erfolgreich beobachteter Antwort-Datenstrom war:
```text
57 03 06 05 0C 01 03 06 02 0F 01 20
```
Damit wurde die bidirektionale Übertragung zwischen dem virtuellen
seriellen Port des Clients und dem realen SPID-Rot2Prog über TCP
erfolgreich nachgewiesen.
## Geräte-Simulator
Das Programm `rs2322tcp-device-sim` dient als Simulator für ein an der
@ -272,13 +850,13 @@ Auch Groß- und Kleinschreibung kann gemischt werden:
0a FF 01 b7
```
Ungültige Eingaben wie
Ungültige Eingaben wie:
```text
GG
```
oder
oder:
```text
4
@ -305,8 +883,8 @@ Für die Entwicklung existiert zusätzlich das separate Testprogramm:
~/rs2322tcp-pty-test
```
Es erzeugt unter Linux ein PTY, setzt den Slave in den Raw Mode und zeigt
den Namen des Slave-Gerätes an.
Es erzeugt unter Linux ein PTY, setzt den Slave in den Raw Mode und stellt
eine simulierte serielle Gegenstelle bereit.
Beispiel:
@ -354,8 +932,7 @@ empfangenen Bytes lediglich an.
## Externe USB-to-Seriell-Adapter
Für den späteren praktischen Einsatz sind USB-to-Seriell-Adapter
vorgesehen.
Für den praktischen Einsatz sind USB-to-Seriell-Adapter vorgesehen.
Unter Linux werden solche Adapter normalerweise als serielle Gerätedateien
bereitgestellt, beispielsweise:
@ -392,12 +969,28 @@ go test -race ./...
```
Der Race Detector wird eingesetzt, um Probleme bei der nebenläufigen
Verarbeitung von Sessions, Data-Listenern, DataConnections und Client-
Komponenten zu erkennen.
Verarbeitung von Sessions, Data-Listenern, DataConnections und
Client-Komponenten zu erkennen.
Die Linux-PTY-Komponente wurde mit einem vollständigen Bytebereich von
`0x00` bis `0xFF` geprüft.
Die Verwaltung der virtuellen Ports verfügt über Tests für:
- automatische Portreservierung
- gezielte Reservierung einer konfigurierten Portnummer
- Freigabe virtueller Ports
- Erstellung und Entfernung der internen PTY-Links
- Abgleich vorhandener virtueller Ports mit der Client-Konfiguration
Die Gerätezuordnung verfügt über Tests für:
- Zuordnung von Geräten zu virtuellen Ports
- Freigabe virtueller Ports
- Vermeidung doppelter Zuordnungen
- Speicherung der Konfiguration
- Laden der gespeicherten Zuordnung
Der Geräte-Simulator verfügt zusätzlich über automatisierte Tests für
die Hex-Konvertierung und die Behandlung ungültiger Eingaben.
@ -413,8 +1006,11 @@ git status
```text
rs2322tcp/
├── cmd/
│ ├── com0com-test/
│ ├── rs2322tcp-client/
│ ├── rs2322tcp-client-test/
│ ├── rs2322tcp-device-sim/
│ ├── rs2322tcp-pty-test/
│ └── rs2322tcp-server/
├── configs/
├── docs/
@ -458,6 +1054,9 @@ Aktuell werden folgende Builds erzeugt:
- Linux armv7 -- Server
- Linux armv6 -- Server
Die gemeinsame Client-Architektur ist für eine spätere
Windows-Unterstützung vorbereitet.
## Versionierung
Die Versionierung erfolgt über Git.
@ -468,17 +1067,20 @@ Build-Datum erfasst.
Release-Versionen werden über Git-Tags gekennzeichnet.
Aktueller Stand:
Aktueller Entwicklungsstand:
- `v0.0.1` erste Projektversion
- `v0.0.1` Projektgrundlage und Buildsystem
- `v0.0.2` Client-Control- und Data-Verbindungen
- `v0.0.3` Linux-PTY-Unterstützung und Geräte-Simulator
- `v0.0.4` in Vorbereitung: interaktive TX-Funktion und automatisierte
Tests des Geräte-Simulators
- `v0.0.4` interaktive TX-Funktion des Geräte-Simulators
- `v0.0.5` konfigurierbare lokale virtuelle serielle Ports
- `v0.0.6` Client-Runtime und Serial-Bridge
- `0.0.7` Geräteerkennung und grafische Gerätezuordnung
- `0.0.8` Validierung der virtuellen Ports und Überarbeitung der Client-GUI
## Entwicklungsstand
Das Projekt befindet sich weiterhin in der frühen Entwicklungsphase.
Das Projekt befindet sich weiterhin in der Entwicklungsphase.
Implementiert und getestet sind derzeit:
@ -491,7 +1093,8 @@ Implementiert und getestet sind derzeit:
- Konfiguration der seriellen Parameter
- Client-Control-Verbindung
- Client-DataConnection
- Client-Integrationstests
- Client-Runtime zur Verwaltung mehrerer virtueller Ports
- gezielte Zuordnung konfigurierter virtueller Portnummern
- Linux-PTY-Abstraktion
- Linux-PTY im Raw Mode
- automatisierter bidirektionaler 256-Byte-PTY-Test
@ -500,13 +1103,22 @@ Implementiert und getestet sind derzeit:
- eigenständiger Geräte-Simulator mit RX-Hexdump
- interaktive TX-Hex-Eingabe des Geräte-Simulators
- automatisierte Tests der Hex-Konvertierung
- grafische Gerätezuordnung im Client
- Speicherung der Gerätezuordnung in `client.json`
- Laden der Gerätezuordnung beim Neustart
- Validierung der vorhandenen virtuellen Ports beim Clientstart
- Abgleich von vorhandenen Symlinks und konfigurierten virtuellen Ports
- Linux-Portverwaltung ohne Root-Rechte im laufenden Client
- verständlicher Fehlerdialog bei fehlerhafter Portkonfiguration
- anpassbare allgemeine Schriftgröße der GUI
- überarbeitete Fenstergrößen der GUI
Noch nicht vollständig implementiert sind insbesondere:
- die vollständige Anbindung der virtuellen seriellen Schnittstelle an
den produktiven Client-Anwendungsdatenpfad
- die Windows-Implementierung der virtuellen seriellen Schnittstelle
- automatische Geräteantworten und Protokollsimulation
- weitere produktive Funktionen des Clients und der GUI
- weiterführende Diagnose- und Logging-Funktionen
## Lizenz

239
cmd/com0com-test/main.go Normal file
View file

@ -0,0 +1,239 @@
//go:build windows
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: cmd/com0com-test/main.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Direkter Test eines com0com-Portpaares.
*
* Der Test öffnet COM100 und COM10 gleichzeitig und überprüft die
* Datenübertragung in beide Richtungen:
*
* COM100 -> COM10
* COM10 -> COM100
*
* Dadurch kann die Funktion von com0com und go.bug.st/serial unabhängig
* vom rs2322tcp-Client, TCP-Server und PTY-Testaufbau geprüft werden.
* ============================================================================
*/
package main
import (
"fmt"
"log"
"time"
"go.bug.st/serial"
)
func main() {
fmt.Println("rs2322tcp com0com Direkt-Test")
fmt.Println()
mode := &serial.Mode{
BaudRate: 600,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
}
com10, err := serial.Open("COM10", mode)
if err != nil {
log.Fatalf("COM10 öffnen: %v", err)
}
defer com10.Close()
fmt.Println("COM10 geöffnet.")
com100, err := serial.Open("COM100", mode)
if err != nil {
log.Fatalf("COM100 öffnen: %v", err)
}
defer com100.Close()
fmt.Println("COM100 geöffnet.")
fmt.Println()
// ------------------------------------------------------------------------
// Test 1: COM100 -> COM10
// ------------------------------------------------------------------------
request := []byte{
0x57,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x1F,
0x20,
}
fmt.Printf(
"COM100 TX: %d Bytes\n",
len(request),
)
printHex(request)
n, err := com100.Write(request)
if err != nil {
log.Fatalf("COM100 schreiben: %v", err)
}
if n != len(request) {
log.Fatalf(
"COM100 schreiben: %d von %d Bytes geschrieben",
n,
len(request),
)
}
fmt.Println("COM100 schreiben erfolgreich.")
received := make([]byte, len(request))
if err := com10.SetReadTimeout(2 * time.Second); err != nil {
log.Fatalf("COM10 ReadTimeout setzen: %v", err)
}
n, err = com10.Read(received)
if err != nil {
log.Fatalf("COM10 lesen: %v", err)
}
fmt.Printf(
"COM10 RX: %d Bytes\n",
n,
)
printHex(received[:n])
if n != len(request) {
log.Fatalf(
"COM10: %d Bytes empfangen, erwartet %d",
n,
len(request),
)
}
if !equalBytes(request, received[:n]) {
log.Fatal("COM10: empfangene Daten stimmen nicht mit den gesendeten Daten überein")
}
fmt.Println("Test COM100 -> COM10 erfolgreich.")
fmt.Println()
// ------------------------------------------------------------------------
// Test 2: COM10 -> COM100
// ------------------------------------------------------------------------
response := []byte{
0x57,
0x07,
0x01,
0x06,
0x00,
0x01,
0x03,
0x06,
0x02,
0x00,
0x01,
0x20,
}
fmt.Printf(
"COM10 TX: %d Bytes\n",
len(response),
)
printHex(response)
n, err = com10.Write(response)
if err != nil {
log.Fatalf("COM10 schreiben: %v", err)
}
if n != len(response) {
log.Fatalf(
"COM10 schreiben: %d von %d Bytes geschrieben",
n,
len(response),
)
}
fmt.Println("COM10 schreiben erfolgreich.")
received = make([]byte, len(response))
if err := com100.SetReadTimeout(2 * time.Second); err != nil {
log.Fatalf("COM100 ReadTimeout setzen: %v", err)
}
n, err = com100.Read(received)
if err != nil {
log.Fatalf("COM100 lesen: %v", err)
}
fmt.Printf(
"COM100 RX: %d Bytes\n",
n,
)
printHex(received[:n])
if n != len(response) {
log.Fatalf(
"COM100: %d Bytes empfangen, erwartet %d",
n,
len(response),
)
}
if !equalBytes(response, received[:n]) {
log.Fatal("COM100: empfangene Daten stimmen nicht mit den gesendeten Daten überein")
}
fmt.Println("Test COM10 -> COM100 erfolgreich.")
fmt.Println()
fmt.Println("==========================================")
fmt.Println("Alle com0com-Direkt-Tests erfolgreich.")
fmt.Println("==========================================")
}
// printHex gibt einen Byte-Block hexadezimal aus.
func printHex(data []byte) {
for i, b := range data {
if i > 0 {
fmt.Print(" ")
}
fmt.Printf("%02X", b)
}
fmt.Println()
}
// equalBytes compares two byte slices.
func equalBytes(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View file

@ -0,0 +1,44 @@
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"git.lang-dieter.de/rs2322tcp/internal/client"
)
func main() {
const configFile = "./configs/client.json"
application, err := client.NewApplication(configFile)
if err != nil {
fmt.Fprintf(os.Stderr, "NewApplication: %v\n", err)
os.Exit(1)
}
if err := application.Start(); err != nil {
fmt.Fprintf(os.Stderr, "Application.Start: %v\n", err)
os.Exit(1)
}
fmt.Println("rs2322tcp technical client started")
fmt.Println("Press Ctrl+C to stop.")
signals := make(chan os.Signal, 1)
signal.Notify(
signals,
os.Interrupt,
syscall.SIGTERM,
)
<-signals
fmt.Println("Stopping client...")
if err := application.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Application.Close: %v\n", err)
os.Exit(1)
}
}

View file

@ -1,23 +1,140 @@
/*
Package main implements the rs2322tcp client application.
The client will provide a local virtual serial interface and
transparently transport serial data over TCP to the rs2322tcp server.
At this stage the application only provides the basic program
structure and version information.
Project: rs2322tcp
Module: git.lang-dieter.de/rs2322tcp
*/
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: cmd/rs2322tcp-client/main.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Einstiegspunkt für den grafischen rs2322tcp-Client.
*
* Der Client wird als eigenständige Desktop-Anwendung ausgeführt. Die
* grafische Oberfläche basiert auf Fyne und bildet sowohl die Statusanzeige
* als auch später die Konfiguration der lokalen virtuellen seriellen
* Ports ab.
*
* Die eigentliche Client- und Runtime-Logik bleibt in den internen
* Packages gekapselt. Diese Datei ist bewusst auf den Programmstart
* und die Übergabe an die GUI beschränkt.
* ============================================================================
*/
package main
import (
"fmt"
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/theme"
"git.lang-dieter.de/rs2322tcp/internal/gui"
"git.lang-dieter.de/rs2322tcp/internal/version"
)
func main() {
fmt.Printf("rs2322tcp-client %s\n", version.Version)
///////////////////////////////////////////////////////////////////////////////
// Application theme
///////////////////////////////////////////////////////////////////////////////
// applicationTheme keeps the standard Fyne theme and increases the general
// text size for better readability.
//
// All other colors, fonts, icons and dimensions remain unchanged.
type applicationTheme struct{}
// Ensure applicationTheme implements fyne.Theme.
var _ fyne.Theme = (*applicationTheme)(nil)
// Color returns the standard Fyne theme color.
func (t *applicationTheme) Color(
name fyne.ThemeColorName,
variant fyne.ThemeVariant,
) color.Color {
return theme.DefaultTheme().Color(name, variant)
}
// Font returns the standard Fyne theme font.
func (t *applicationTheme) Font(
style fyne.TextStyle,
) fyne.Resource {
return theme.DefaultTheme().Font(style)
}
// Icon returns the standard Fyne theme icon.
func (t *applicationTheme) Icon(
name fyne.ThemeIconName,
) fyne.Resource {
return theme.DefaultTheme().Icon(name)
}
// Size returns the standard Fyne theme size, except for normal text.
//
// Normal text is increased from the Fyne default to 22 px for better
// readability.
func (t *applicationTheme) Size(
name fyne.ThemeSizeName,
) float32 {
if name == theme.SizeNameText {
return 22
}
return theme.DefaultTheme().Size(name)
}
///////////////////////////////////////////////////////////////////////////////
// Main
///////////////////////////////////////////////////////////////////////////////
func main() {
fmt.Printf(
"rs2322tcp-client %s\n",
version.Version,
)
application := app.NewWithID(
"git.lang-dieter.de.rs2322tcp.client",
)
// Use the standard Fyne theme with a larger general text size.
application.Settings().SetTheme(
&applicationTheme{},
)
guiApplication, err := gui.NewApp(
application,
"./configs/client.json",
)
if err != nil {
fmt.Printf(
"GUI konnte nicht gestartet werden: %v\n",
err,
)
return
}
// SetOnClosed gehört zum Fyne-Fenster, nicht zur Fyne-Anwendung.
//
// Beim Schließen des Hauptfensters wird die technische Client-
// Anwendung beendet. Dadurch werden Runtime, Bridges, virtuelle
// Ports und die Serververbindung sauber geschlossen.
guiApplication.Window().SetOnClosed(
func() {
guiApplication.Close()
},
)
// Zuerst das GUI-Fenster erzeugen und anzeigen.
//
// Dadurch wird ein möglicher Fehler beim Start von Fyne/GLFW erkannt,
// bevor die technische Client-Kommunikation gestartet wird.
guiApplication.Window().Show()
// Erst nachdem das Fenster erfolgreich erzeugt wurde, wird das
// technische Client-System asynchron gestartet.
guiApplication.Start()
// Den Fyne-Eventloop starten.
application.Run()
}

View file

@ -0,0 +1,348 @@
//go:build linux
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: cmd/rs2322tcp-pty-test/main.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Entwicklungs- und Testwerkzeug zum Bereitstellen eines einzelnen
* bidirektionalen Linux-PTYs als simuliertes serielles Endgerät.
*
* Der erzeugte PTY-Slave wird vom rs2322tcp-server wie eine echte serielle
* Schnittstelle verwendet. Der Helper arbeitet auf dem zugehörigen
* PTY-Master als Gegenstelle.
*
* Alle vom Server empfangenen Daten werden als RX-Hexdump ausgegeben.
*
* Bei der bekannten Rotor-Anfrage wird automatisch die konfigurierte
* Rotor-Antwort zurückgesendet. Alle anderen empfangenen Daten werden
* unverändert als Echo zurückgesendet.
*
* Der PTY-Slave bleibt während der gesamten Laufzeit des Helpers geöffnet.
* Dadurch bleibt die virtuelle serielle Gegenstelle auch dann verfügbar,
* wenn der Server den Port erst nach dem Start des Helpers öffnet.
*
* Beim Beenden werden Master und Slave geschlossen. Der Test-Port
* verschwindet anschließend wieder aus /dev/pts.
* ============================================================================
*/
package main
import (
"bytes"
"fmt"
"io"
"os"
"os/signal"
"syscall"
"time"
"golang.org/x/sys/unix"
)
var (
rotorRequest = []byte{
0x57,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x1F,
0x20,
}
rotorResponse = []byte{
0x57,
0x07,
0x01,
0x06,
0x00,
0x01,
0x03,
0x06,
0x02,
0x00,
0x01,
0x20,
}
)
func main() {
masterFD, slaveFD, slavePath, err := createPTY()
if err != nil {
fmt.Fprintf(
os.Stderr,
"PTY-Erzeugung fehlgeschlagen: %v\n",
err,
)
os.Exit(1)
}
defer unix.Close(masterFD)
defer unix.Close(slaveFD)
fmt.Println("rs2322tcp-pty-test")
fmt.Printf("Port: %s\n", slavePath)
fmt.Println("Modus: Rotor-Simulation mit Echo-Fallback")
fmt.Println("Warte auf Daten ...")
signals := make(chan os.Signal, 1)
signal.Notify(
signals,
os.Interrupt,
syscall.SIGTERM,
)
defer signal.Stop(signals)
done := make(chan struct{})
go func() {
handleSerial(masterFD)
close(done)
}()
select {
case <-signals:
fmt.Println()
fmt.Println("PTY-Testport wird geschlossen.")
case <-done:
fmt.Println()
fmt.Println("PTY-Gegenstelle wurde geschlossen.")
}
}
func handleSerial(fd int) {
buf := make([]byte, 4096)
for {
n, err := unix.Read(fd, buf)
if err != nil {
if err == unix.EINTR {
continue
}
if err == unix.EIO || err == io.EOF {
return
}
fmt.Fprintf(
os.Stderr,
"Lesen vom PTY-Master: %v\n",
err,
)
return
}
if n == 0 {
continue
}
received := append([]byte(nil), buf[:n]...)
printHexDump("RX", received)
response := responseFor(received)
if err := writeAll(fd, response); err != nil {
fmt.Fprintf(
os.Stderr,
"Schreiben zum PTY-Master: %v\n",
err,
)
return
}
printHexDump("TX", response)
}
}
func responseFor(received []byte) []byte {
if bytes.Equal(received, rotorRequest) {
return rotorResponse
}
return received
}
func createPTY() (int, int, string, error) {
masterFD, err := unix.Open(
"/dev/ptmx",
unix.O_RDWR|unix.O_NOCTTY,
0,
)
if err != nil {
return -1, -1, "", fmt.Errorf(
"open PTY master: %w",
err,
)
}
closeMasterOnError := true
defer func() {
if closeMasterOnError {
_ = unix.Close(masterFD)
}
}()
if err := unix.IoctlSetPointerInt(
masterFD,
unix.TIOCSPTLCK,
0,
); err != nil {
return -1, -1, "", fmt.Errorf(
"unlock PTY slave: %w",
err,
)
}
ptsNumber, err := unix.IoctlGetInt(
masterFD,
unix.TIOCGPTN,
)
if err != nil {
return -1, -1, "", fmt.Errorf(
"get PTY number: %w",
err,
)
}
slavePath := fmt.Sprintf(
"/dev/pts/%d",
ptsNumber,
)
slaveFD, err := unix.Open(
slavePath,
unix.O_RDWR|unix.O_NOCTTY,
0,
)
if err != nil {
return -1, -1, "", fmt.Errorf(
"open PTY slave %s: %w",
slavePath,
err,
)
}
closeSlaveOnError := true
defer func() {
if closeSlaveOnError {
_ = unix.Close(slaveFD)
}
}()
if err := configurePTY(slaveFD); err != nil {
return -1, -1, "", err
}
closeMasterOnError = false
closeSlaveOnError = false
return masterFD, slaveFD, slavePath, nil
}
func configurePTY(fd int) error {
termios, err := unix.IoctlGetTermios(
fd,
unix.TCGETS,
)
if err != nil {
return fmt.Errorf(
"get PTY termios: %w",
err,
)
}
termios.Iflag &^= unix.IGNBRK |
unix.BRKINT |
unix.PARMRK |
unix.ISTRIP |
unix.INLCR |
unix.IGNCR |
unix.ICRNL |
unix.IXON
termios.Oflag &^= unix.OPOST
termios.Lflag &^= unix.ECHO |
unix.ECHONL |
unix.ICANON |
unix.ISIG |
unix.IEXTEN
termios.Cflag &^= unix.CSIZE | unix.PARENB
termios.Cflag |= unix.CS8
termios.Cc[unix.VMIN] = 1
termios.Cc[unix.VTIME] = 0
if err := unix.IoctlSetTermios(
fd,
unix.TCSETS,
termios,
); err != nil {
return fmt.Errorf(
"set PTY raw mode: %w",
err,
)
}
return nil
}
func writeAll(fd int, data []byte) error {
for len(data) > 0 {
n, err := unix.Write(fd, data)
if err != nil {
if err == unix.EINTR {
continue
}
return err
}
if n == 0 {
return io.ErrShortWrite
}
data = data[n:]
}
return nil
}
func printHexDump(direction string, data []byte) {
fmt.Printf(
"%s %s %d Bytes\n",
time.Now().Format("15:04:05.000"),
direction,
len(data),
)
for offset := 0; offset < len(data); offset += 16 {
end := offset + 16
if end > len(data) {
end = len(data)
}
fmt.Print(" ")
for _, b := range data[offset:end] {
fmt.Printf("%02X ", b)
}
fmt.Println()
}
}

View file

@ -1,12 +1,9 @@
/*
Package main implements the rs2322tcp server application.
The server will provide the network endpoint for rs2322tcp clients
and connect incoming connections to configured physical RS232
interfaces.
At this stage the application only provides the basic program
structure and version information.
The server provides the network endpoint for rs2322tcp clients
and connects incoming data connections to configured physical
RS232 interfaces.
Project: rs2322tcp
Module: git.lang-dieter.de/rs2322tcp
@ -14,11 +11,56 @@ Module: git.lang-dieter.de/rs2322tcp
package main
import (
"flag"
"fmt"
"log"
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/server"
"git.lang-dieter.de/rs2322tcp/internal/version"
)
func main() {
fmt.Printf("rs2322tcp-server %s\n", version.Version)
configFile := flag.String(
"config",
"./configs/server.json",
"Pfad zur Server-Konfigurationsdatei",
)
flag.Parse()
cfg, err := config.LoadServer(*configFile)
if err != nil {
log.Fatalf("Server-Konfiguration laden: %v", err)
}
controlServer, err := server.NewControlServer(cfg)
if err != nil {
log.Fatalf("Control-Server erstellen: %v", err)
}
if err := controlServer.Listen(); err != nil {
log.Fatalf("Control-Server starten: %v", err)
}
defer controlServer.Close()
fmt.Printf(
"rs2322tcp-server %s\n",
version.Version,
)
fmt.Printf(
"Control-Server gestartet auf %s\n",
controlServer.Addr(),
)
fmt.Printf(
"Geräte: %d\n",
len(cfg.Devices),
)
if err := controlServer.Serve(); err != nil {
log.Fatalf("Control-Server beendet: %v", err)
}
}

View file

@ -1,16 +1,18 @@
{
"server": {
"address": "100.64.0.10",
"address": "127.0.0.1",
"port": 5000
},
"virtual_port_range": {
"first": 100,
"last": 199
},
"virtual_ports": [
{
"port": "COM7",
"remote_device": "radio"
},
{
"port": "COM8",
"remote_device": "rotor"
"port": "/dev/ttyUSB100",
"remote_device": "rotor",
"startup_bytes": "57 00 00 00 00 00 00 00 00 00 00 1F 20"
}
]
}

41
configs/server-test.json Normal file
View file

@ -0,0 +1,41 @@
{
"listen": {
"address": "0.0.0.0",
"port": 5000
},
"hardware_error_response": "ERROR - HARDWARE NOT AVAILABLE",
"serial_monitor": false,
"devices": [
{
"id": "radio",
"name": "Funkgerät",
"serial_port": "/dev/ttyUSB1",
"baud_rate": 9600,
"data_bits": 8,
"parity": "none",
"stop_bits": 1
},
{
"id": "rotor",
"name": "Antennenrotor",
"serial_port": "/dev/pts/1",
"baud_rate": 600,
"data_bits": 8,
"parity": "none",
"stop_bits": 1
},
{
"id": "gps",
"name": "GPS-Testgerät",
"serial_port": "/dev/ttyUSB3",
"baud_rate": 600,
"data_bits": 8,
"parity": "none",
"stop_bits": 1
}
]
}

View file

@ -3,11 +3,16 @@
"address": "0.0.0.0",
"port": 5000
},
"hardware_error_response": "ERROR - HARDWARE NOT AVAILABLE",
"serial_monitor": true,
"devices": [
{
"id": "radio",
"name": "Funkgerät",
"serial_port": "/dev/ttyUSB0",
"serial_port": "/dev/ttyUSB1",
"baud_rate": 9600,
"data_bits": 8,
"parity": "none",
@ -16,11 +21,21 @@
{
"id": "rotor",
"name": "Antennenrotor",
"serial_port": "/dev/ttyUSB1",
"baud_rate": 4800,
"serial_port": "/dev/ttyUSB0",
"baud_rate": 600,
"data_bits": 8,
"parity": "none",
"stop_bits": 1
},
{
"id": "gps",
"name": "GPS-Testgerät",
"serial_port": "/dev/ttyUSB3",
"baud_rate": 600,
"data_bits": 8,
"parity": "none",
"stop_bits": 1
}
]
}

44
go.mod
View file

@ -2,6 +2,46 @@ module git.lang-dieter.de/rs2322tcp
go 1.25.0
require go.bug.st/serial v1.7.1
require (
go.bug.st/serial v1.7.1
golang.org/x/sys v0.43.0
)
require golang.org/x/sys v0.43.0 // indirect
require github.com/FyshOS/fancyfs v0.0.1 // indirect
require (
fyne.io/fyne/v2 v2.8.0
fyne.io/systray v1.12.2 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/anthonynsimon/bild v0.14.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fredbi/uri v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect
github.com/fyne-io/glfw-js v0.4.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.2.0 // indirect
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a // indirect
github.com/go-text/render v0.2.1 // indirect
github.com/go-text/typesetting v0.3.4 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rymdport/portal v0.4.2 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/text v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

85
go.sum
View file

@ -1,4 +1,89 @@
fyne.io/fyne/v2 v2.8.0 h1:KNUdIk1eKsXSPy/wU6MdiR1hppAPvyzbjPbtJ8h6EUQ=
fyne.io/fyne/v2 v2.8.0/go.mod h1:tLJK7CVtUBOnMiSDR+J88t/quiGuEhwGs09tIVM1RXg=
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg=
github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A=
github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo=
github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a h1:HWK0MBggT/T6YH7VffE10xBIhqeTq8JzIUPJXrRy87g=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8=
github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg=
github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A=
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc=
go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,347 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/application.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Gemeinsame Anwendungsschicht für den rs2322tcp-Client.
*
* Die Anwendungsschicht verbindet die Konfiguration, die Control-Verbindung
* zum Server und die clientseitige Runtime. Sie stellt damit einen einfachen
* Lebenszyklus für die spätere grafische Benutzeroberfläche bereit.
*
* Der technische Client wird bei Start und Reconnect vollständig neu
* initialisiert:
*
* Konfiguration laden
* |
* v
* Lokale virtuelle Ports prüfen
* |
* v
* Server verbinden
* |
* v
* Geräteliste abfragen
* |
* v
* Runtime starten
*
* Ein Reconnect beendet zunächst die bestehende Runtime und Control-
* Verbindung und führt anschließend denselben Startablauf erneut aus.
*
* Die Anwendungsschicht enthält bewusst keine GUI-Logik und keine
* Fyne-Abhängigkeit.
* ============================================================================
*/
package client
import (
"fmt"
"sync"
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
///////////////////////////////////////////////////////////////////////////////
// Application
///////////////////////////////////////////////////////////////////////////////
// Application represents one running rs2322tcp client application.
//
// Application owns the current client connection and Runtime. The
// configuration file is the persistent source of the client configuration.
//
// The Application is intended to be used by the command-line entry point
// as well as by the later graphical user interface.
type Application struct {
mu sync.Mutex
configFile string
config *config.ClientConfig
client *Client
runtime *Runtime
devices []transport.RemoteDeviceInfo
runtimeDone chan error
}
// NewApplication creates a new client Application.
//
// The client configuration is loaded immediately. No network connection
// is established. Start must be called before the application connects to
// the server or starts the Runtime.
func NewApplication(configFile string) (*Application, error) {
if configFile == "" {
return nil, fmt.Errorf("configuration file is empty")
}
cfg, err := config.LoadClient(configFile)
if err != nil {
return nil, fmt.Errorf("load client configuration: %w", err)
}
return &Application{
configFile: configFile,
config: cfg,
}, nil
}
///////////////////////////////////////////////////////////////////////////////
// Start
///////////////////////////////////////////////////////////////////////////////
// Start loads the client configuration, validates the locally available
// virtual ports, connects to the server and starts the client Runtime.
//
// The Runtime is started asynchronously because Runtime.Run blocks while
// the configured virtual serial connections are active.
func (a *Application) Start() error {
if a == nil {
return fmt.Errorf("application is nil")
}
a.mu.Lock()
if a.runtime != nil || a.client != nil {
a.mu.Unlock()
return fmt.Errorf("application is already running")
}
a.mu.Unlock()
cfg, err := config.LoadClient(a.configFile)
if err != nil {
return fmt.Errorf("load client configuration: %w", err)
}
// Before connecting to the server, make sure that the virtual ports
// configured in client.json match the locally installed virtual ports.
//
// This check is deliberately performed before New(address), so an
// invalid local virtual-port configuration prevents the client from
// starting at all.
if err := validateLocalVirtualPorts(cfg); err != nil {
return err
}
address := fmt.Sprintf(
"%s:%d",
cfg.Server.Address,
cfg.Server.Port,
)
client, err := New(address)
if err != nil {
return err
}
devices, err := client.GetDevices()
if err != nil {
_ = client.Close()
return fmt.Errorf("get remote devices: %w", err)
}
manager := NewVirtualPortManager(
cfg.VirtualPortRange,
)
runtime, err := NewRuntime(
client,
manager,
)
if err != nil {
_ = client.Close()
return fmt.Errorf("create runtime: %w", err)
}
runtimeDone := make(chan error, 1)
a.mu.Lock()
a.config = cfg
a.client = client
a.runtime = runtime
a.devices = append(
[]transport.RemoteDeviceInfo(nil),
devices...,
)
a.runtimeDone = runtimeDone
a.mu.Unlock()
go func() {
runtimeDone <- runtime.Run(*cfg)
}()
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Reconnect
///////////////////////////////////////////////////////////////////////////////
// Reconnect stops the current client connection and starts it again.
//
// The configuration file is loaded again during Start. This is intentional:
// changes made by the GUI are therefore picked up automatically.
//
// Reconnect is also useful when the server connection has been lost or the
// user explicitly requests a new connection.
func (a *Application) Reconnect() error {
if a == nil {
return fmt.Errorf("application is nil")
}
if err := a.Close(); err != nil {
return fmt.Errorf("close current connection: %w", err)
}
return a.Start()
}
///////////////////////////////////////////////////////////////////////////////
// Close
///////////////////////////////////////////////////////////////////////////////
// Close stops the Runtime and closes the client control connection.
//
// Calling Close more than once is safe.
func (a *Application) Close() error {
if a == nil {
return nil
}
a.mu.Lock()
runtime := a.runtime
client := a.client
a.runtime = nil
a.client = nil
a.devices = nil
a.runtimeDone = nil
a.mu.Unlock()
var firstErr error
if runtime != nil {
if err := runtime.Close(); err != nil {
firstErr = err
}
} else if client != nil {
if err := client.Close(); err != nil {
firstErr = err
}
}
return firstErr
}
///////////////////////////////////////////////////////////////////////////////
// Configuration
///////////////////////////////////////////////////////////////////////////////
// Config returns a copy of the currently loaded client configuration.
//
// The configuration is available after NewApplication, even when the
// server connection has not yet been established.
func (a *Application) Config() *config.ClientConfig {
if a == nil {
return nil
}
a.mu.Lock()
defer a.mu.Unlock()
if a.config == nil {
return nil
}
cfg := *a.config
cfg.VirtualPorts = append(
[]config.VirtualPortConfig(nil),
a.config.VirtualPorts...,
)
return &cfg
}
// SaveConfig saves the supplied client configuration to the configuration
// file used by this Application.
//
// After a successful save the supplied configuration also becomes the
// current in-memory configuration.
//
// The running Runtime is not restarted automatically. A subsequent
// Reconnect or application restart will use the saved configuration.
func (a *Application) SaveConfig(
cfg *config.ClientConfig,
) error {
if a == nil {
return fmt.Errorf("application is nil")
}
if cfg == nil {
return fmt.Errorf("client configuration is nil")
}
if err := config.SaveClient(a.configFile, cfg); err != nil {
return err
}
a.mu.Lock()
a.config = cfg
a.mu.Unlock()
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Devices
///////////////////////////////////////////////////////////////////////////////
// Devices returns a copy of the remote devices received from the server.
//
// The returned slice belongs to the caller and can therefore be modified
// without changing the Application state.
func (a *Application) Devices() []transport.RemoteDeviceInfo {
if a == nil {
return nil
}
a.mu.Lock()
defer a.mu.Unlock()
return append(
[]transport.RemoteDeviceInfo(nil),
a.devices...,
)
}
///////////////////////////////////////////////////////////////////////////////
// Runtime state
///////////////////////////////////////////////////////////////////////////////
// Running reports whether the application currently owns a Runtime and a
// client connection.
func (a *Application) Running() bool {
if a == nil {
return false
}
a.mu.Lock()
defer a.mu.Unlock()
return a.runtime != nil && a.client != nil
}

View file

@ -0,0 +1,344 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/application_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für die Anwendungsschicht des rs2322tcp-Clients.
*
* Die Tests prüfen den Lebenszyklus der Application und die lokale
* Konfigurationsverwaltung, ohne dafür eine echte Serververbindung
* aufbauen zu müssen.
*
* Insbesondere wird sichergestellt, dass eine neu erzeugte Application
* ihre lokale Konfiguration bereits kennt, obwohl noch keine Verbindung
* zum Server besteht.
* ============================================================================
*/
package client
import (
"os"
"path/filepath"
"testing"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
///////////////////////////////////////////////////////////////////////////////
// Test helpers
///////////////////////////////////////////////////////////////////////////////
// writeTestConfig creates a temporary client configuration file.
func writeTestConfig(t *testing.T) string {
t.Helper()
dir := t.TempDir()
filename := filepath.Join(dir, "client.json")
content := `{
"server": {
"address": "127.0.0.1",
"port": 5000
},
"virtual_port_range": {
"first": 100,
"last": 199
},
"virtual_ports": [
{
"port": "/dev/ttyUSB100",
"remote_device": "radio"
},
{
"port": "/dev/ttyUSB101",
"remote_device": "rotor"
}
]
}`
if err := os.WriteFile(
filename,
[]byte(content),
0600,
); err != nil {
t.Fatalf("write test configuration: %v", err)
}
return filename
}
///////////////////////////////////////////////////////////////////////////////
// NewApplication
///////////////////////////////////////////////////////////////////////////////
// TestNewApplicationRejectsEmptyConfigFile verifies that an empty
// configuration-file path is rejected.
func TestNewApplicationRejectsEmptyConfigFile(t *testing.T) {
t.Helper()
application, err := NewApplication("")
if err == nil {
t.Fatal("NewApplication with empty config file returned no error")
}
if application != nil {
t.Fatal("NewApplication with empty config file returned an application")
}
}
// TestNewApplication verifies that a valid configuration-file path creates
// an application and loads the local configuration without starting the
// network connection.
func TestNewApplication(t *testing.T) {
t.Helper()
configFile := writeTestConfig(t)
application, err := NewApplication(configFile)
if err != nil {
t.Fatalf("NewApplication: %v", err)
}
if application == nil {
t.Fatal("NewApplication returned nil application")
}
if application.Running() {
t.Fatal("new application reports running")
}
cfg := application.Config()
if cfg == nil {
t.Fatal("new application returned no configuration")
}
if len(cfg.VirtualPorts) != 2 {
t.Fatalf(
"new application returned %d virtual ports, want 2",
len(cfg.VirtualPorts),
)
}
if cfg.VirtualPorts[0].Port != "/dev/ttyUSB100" {
t.Errorf(
"virtual port 0 = %q, want %q",
cfg.VirtualPorts[0].Port,
"/dev/ttyUSB100",
)
}
if cfg.VirtualPorts[0].RemoteDevice != "radio" {
t.Errorf(
"remote device 0 = %q, want %q",
cfg.VirtualPorts[0].RemoteDevice,
"radio",
)
}
if cfg.VirtualPorts[1].Port != "/dev/ttyUSB101" {
t.Errorf(
"virtual port 1 = %q, want %q",
cfg.VirtualPorts[1].Port,
"/dev/ttyUSB101",
)
}
if cfg.VirtualPorts[1].RemoteDevice != "rotor" {
t.Errorf(
"remote device 1 = %q, want %q",
cfg.VirtualPorts[1].RemoteDevice,
"rotor",
)
}
if devices := application.Devices(); len(devices) != 0 {
t.Fatalf(
"new application returned %d devices, want 0",
len(devices),
)
}
}
///////////////////////////////////////////////////////////////////////////////
// Configuration copy
///////////////////////////////////////////////////////////////////////////////
// TestApplicationConfigReturnsCopy verifies that modifying the returned
// configuration does not modify the Application's internal configuration.
func TestApplicationConfigReturnsCopy(t *testing.T) {
t.Helper()
configFile := writeTestConfig(t)
application, err := NewApplication(configFile)
if err != nil {
t.Fatalf("NewApplication: %v", err)
}
cfg := application.Config()
if cfg == nil {
t.Fatal("Config returned nil")
}
cfg.VirtualPorts[0].Port = "/dev/ttyUSB999"
cfg.VirtualPorts[0].RemoteDevice = "changed"
current := application.Config()
if current == nil {
t.Fatal("second Config returned nil")
}
if current.VirtualPorts[0].Port != "/dev/ttyUSB100" {
t.Errorf(
"internal port changed to %q",
current.VirtualPorts[0].Port,
)
}
if current.VirtualPorts[0].RemoteDevice != "radio" {
t.Errorf(
"internal remote device changed to %q",
current.VirtualPorts[0].RemoteDevice,
)
}
}
///////////////////////////////////////////////////////////////////////////////
// Close
///////////////////////////////////////////////////////////////////////////////
// TestApplicationCloseBeforeStart verifies that Close can safely be called
// before the application has been started and that the local configuration
// remains available.
func TestApplicationCloseBeforeStart(t *testing.T) {
t.Helper()
configFile := writeTestConfig(t)
application, err := NewApplication(configFile)
if err != nil {
t.Fatalf("NewApplication: %v", err)
}
if err := application.Close(); err != nil {
t.Fatalf("Close before Start: %v", err)
}
if application.Running() {
t.Fatal("application reports running after Close")
}
if cfg := application.Config(); cfg == nil {
t.Fatal("configuration was lost after Close")
}
}
// TestApplicationCloseIsIdempotent verifies that Close can be called more
// than once without producing an error and without losing the local
// configuration.
func TestApplicationCloseIsIdempotent(t *testing.T) {
t.Helper()
configFile := writeTestConfig(t)
application, err := NewApplication(configFile)
if err != nil {
t.Fatalf("NewApplication: %v", err)
}
if err := application.Close(); err != nil {
t.Fatalf("first Close: %v", err)
}
if err := application.Close(); err != nil {
t.Fatalf("second Close: %v", err)
}
if application.Running() {
t.Fatal("application reports running after repeated Close")
}
if cfg := application.Config(); cfg == nil {
t.Fatal("configuration was lost after repeated Close")
}
}
///////////////////////////////////////////////////////////////////////////////
// Invalid configuration
///////////////////////////////////////////////////////////////////////////////
// TestNewApplicationRejectsInvalidConfiguration verifies that an invalid
// configuration file is rejected immediately.
func TestNewApplicationRejectsInvalidConfiguration(t *testing.T) {
t.Helper()
dir := t.TempDir()
filename := filepath.Join(dir, "invalid.json")
if err := os.WriteFile(
filename,
[]byte(`{"invalid": true}`),
0600,
); err != nil {
t.Fatalf("write invalid configuration: %v", err)
}
application, err := NewApplication(filename)
if err == nil {
t.Fatal("NewApplication with invalid configuration returned no error")
}
if application != nil {
t.Fatal(
"NewApplication with invalid configuration returned an application",
)
}
}
///////////////////////////////////////////////////////////////////////////////
// Nil receiver
///////////////////////////////////////////////////////////////////////////////
// TestNilApplication verifies that the public lifecycle methods behave
// safely when called on a nil Application receiver.
//
// This is deliberately a small defensive test. The GUI should normally
// never operate on a nil Application.
func TestNilApplication(t *testing.T) {
t.Helper()
var application *Application
if err := application.Start(); err == nil {
t.Fatal("nil Application Start returned no error")
}
if err := application.Reconnect(); err == nil {
t.Fatal("nil Application Reconnect returned no error")
}
if err := application.Close(); err != nil {
t.Fatalf("nil Application Close: %v", err)
}
if application.Running() {
t.Fatal("nil Application reports running")
}
if application.Config() != nil {
t.Fatal("nil Application returned a configuration")
}
if devices := application.Devices(); devices != nil {
t.Fatal("nil Application returned devices")
}
}
// Keep config imported explicitly so the test documents that the temporary
// file represents the normal ClientConfig structure.
var _ config.ClientConfig

130
internal/client/bridge.go Normal file
View file

@ -0,0 +1,130 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/bridge.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Bidirektionale Daten-Bridge zwischen der virtuellen seriellen
* Schnittstelle des Clients und der TCP-Data-Verbindung.
*
* Die Bridge transportiert den Byte-Strom in beide Richtungen:
*
* VirtualSerial TCP
* VirtualSerial TCP
*
* Die Bridge kennt weder das Control-Protokoll noch die konkrete
* serielle Hardware. Sie verbindet ausschließlich zwei io.ReadWriter-
* Endpunkte miteinander.
* ============================================================================
*/
package client
import (
"fmt"
"io"
"net"
"sync"
)
///////////////////////////////////////////////////////////////////////////////
// Bridge
///////////////////////////////////////////////////////////////////////////////
// Bridge verbindet eine virtuelle serielle Schnittstelle mit einer
// TCP-Data-Verbindung.
//
// Beide Datenrichtungen werden gleichzeitig bedient. Dadurch bleibt der
// serielle Datenstrom vollständig bidirektional.
type Bridge struct {
serial VirtualSerial
conn net.Conn
}
///////////////////////////////////////////////////////////////////////////////
// Constructor
///////////////////////////////////////////////////////////////////////////////
// NewBridge creates a new bidirectional data bridge.
//
// The bridge does not take ownership of the endpoints until Run is called.
func NewBridge(serial VirtualSerial, conn net.Conn) (*Bridge, error) {
if serial == nil {
return nil, fmt.Errorf("virtual serial is nil")
}
if conn == nil {
return nil, fmt.Errorf("TCP connection is nil")
}
return &Bridge{
serial: serial,
conn: conn,
}, nil
}
///////////////////////////////////////////////////////////////////////////////
// Run
///////////////////////////////////////////////////////////////////////////////
// Run starts the bidirectional byte transfer and blocks until one of the
// directions terminates.
//
// Closing the endpoints after termination releases the opposite blocked
// transfer as well.
func (b *Bridge) Run() error {
if b == nil || b.serial == nil {
return fmt.Errorf("bridge is not initialized")
}
if b.conn == nil {
return fmt.Errorf("TCP connection is nil")
}
type result struct {
name string
err error
}
results := make(chan result, 2)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_, err := io.Copy(b.conn, b.serial)
results <- result{
name: "serial to TCP",
err: err,
}
}()
go func() {
defer wg.Done()
_, err := io.Copy(b.serial, b.conn)
results <- result{
name: "TCP to serial",
err: err,
}
}()
first := <-results
// Beide Richtungen sollen beendet werden. Das Schließen der Endpunkte
// unterbricht insbesondere einen eventuell noch blockierenden Read.
_ = b.conn.Close()
_ = b.serial.Close()
wg.Wait()
if first.err != nil {
return fmt.Errorf("%s: %w", first.name, first.err)
}
return nil
}

View file

@ -0,0 +1,271 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/bridge_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für die bidirektionale Daten-Bridge zwischen VirtualSerial und
* TCP-Verbindung.
*
* Die Tests verwenden net.Pipe und eine kleine In-Memory-Implementierung
* von VirtualSerial. Dadurch wird ausschließlich die Daten-Bridge getestet,
* ohne echte TCP-Ports oder eine reale PTY-Schnittstelle zu benötigen.
* ============================================================================
*/
package client
import (
"bytes"
"io"
"net"
"sync"
"testing"
"time"
)
///////////////////////////////////////////////////////////////////////////////
// Test VirtualSerial
///////////////////////////////////////////////////////////////////////////////
type testVirtualSerial struct {
mu sync.Mutex
reader *bytes.Reader
writes [][]byte
closed bool
}
func newTestVirtualSerial(data []byte) *testVirtualSerial {
return &testVirtualSerial{
reader: bytes.NewReader(data),
}
}
func (s *testVirtualSerial) Path() string {
return "test-serial"
}
func (s *testVirtualSerial) Read(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return 0, io.EOF
}
return s.reader.Read(p)
}
func (s *testVirtualSerial) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return 0, io.ErrClosedPipe
}
cp := append([]byte(nil), p...)
s.writes = append(s.writes, cp)
return len(p), nil
}
func (s *testVirtualSerial) Written() []byte {
s.mu.Lock()
defer s.mu.Unlock()
var result []byte
for _, p := range s.writes {
result = append(result, p...)
}
return result
}
func (s *testVirtualSerial) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
s.closed = true
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Blocking test VirtualSerial
///////////////////////////////////////////////////////////////////////////////
type blockingVirtualSerial struct {
mu sync.Mutex
writes [][]byte
readCh chan struct{}
closed bool
}
func newBlockingVirtualSerial() *blockingVirtualSerial {
return &blockingVirtualSerial{
readCh: make(chan struct{}),
}
}
func (s *blockingVirtualSerial) Path() string {
return "blocking-test-serial"
}
func (s *blockingVirtualSerial) Read([]byte) (int, error) {
<-s.readCh
return 0, io.EOF
}
func (s *blockingVirtualSerial) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return 0, io.ErrClosedPipe
}
s.writes = append(s.writes, append([]byte(nil), p...))
return len(p), nil
}
func (s *blockingVirtualSerial) Written() []byte {
s.mu.Lock()
defer s.mu.Unlock()
var result []byte
for _, p := range s.writes {
result = append(result, p...)
}
return result
}
func (s *blockingVirtualSerial) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.closed {
s.closed = true
close(s.readCh)
}
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Tests
///////////////////////////////////////////////////////////////////////////////
func TestNewBridge(t *testing.T) {
serial := newTestVirtualSerial(nil)
connA, connB := net.Pipe()
defer connA.Close()
defer connB.Close()
if _, err := NewBridge(nil, connA); err == nil {
t.Fatal("expected error for nil VirtualSerial")
}
if _, err := NewBridge(serial, nil); err == nil {
t.Fatal("expected error for nil TCP connection")
}
bridge, err := NewBridge(serial, connA)
if err != nil {
t.Fatalf("NewBridge: %v", err)
}
if bridge.serial != serial {
t.Fatal("bridge serial endpoint mismatch")
}
if bridge.conn != connA {
t.Fatal("bridge TCP endpoint mismatch")
}
}
func TestBridgeSerialToTCP(t *testing.T) {
serial := newTestVirtualSerial([]byte("hello"))
clientConn, bridgeConn := net.Pipe()
bridge, err := NewBridge(serial, bridgeConn)
if err != nil {
t.Fatalf("NewBridge: %v", err)
}
runDone := make(chan error, 1)
go func() {
runDone <- bridge.Run()
}()
got := make([]byte, 5)
if _, err := io.ReadFull(clientConn, got); err != nil {
t.Fatalf("read TCP data: %v", err)
}
if string(got) != "hello" {
t.Fatalf("received %q, want %q", got, "hello")
}
_ = clientConn.Close()
select {
case err := <-runDone:
if err != nil {
t.Fatalf("Bridge.Run: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Bridge.Run did not terminate")
}
}
func TestBridgeTCPToSerial(t *testing.T) {
serial := newBlockingVirtualSerial()
clientConn, bridgeConn := net.Pipe()
bridge, err := NewBridge(serial, bridgeConn)
if err != nil {
t.Fatalf("NewBridge: %v", err)
}
runDone := make(chan error, 1)
go func() {
runDone <- bridge.Run()
}()
want := []byte{0x46, 0x41, 0x00, 0x10, 0x0D}
if _, err := clientConn.Write(want); err != nil {
t.Fatalf("write TCP data: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for {
if bytes.Equal(serial.Written(), want) {
break
}
if time.Now().After(deadline) {
t.Fatalf(
"serial received % X, want % X",
serial.Written(),
want,
)
}
time.Sleep(1 * time.Millisecond)
}
_ = clientConn.Close()
select {
case err := <-runDone:
if err != nil {
t.Fatalf("Bridge.Run: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Bridge.Run did not terminate")
}
}

View file

@ -0,0 +1,188 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/connection.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Aufbau einer Verbindung zwischen einem lokalen virtuellen seriellen Port
* und dem zugehörigen entfernten Gerät.
*
* Die konfigurierte lokale virtuelle Schnittstelle wird dabei exakt
* übernommen.
*
* Optional können nach dem Aufbau der Datenverbindung konfigurierte
* Startbytes an das entfernte Gerät gesendet werden.
* ============================================================================
*/
package client
import (
"encoding/hex"
"fmt"
"net"
"strings"
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
///////////////////////////////////////////////////////////////////////////////
// Virtual port connection
///////////////////////////////////////////////////////////////////////////////
// connectVirtualPort opens the configured local virtual port and the
// corresponding remote TCP data connection and returns both endpoints.
//
// The caller owns the returned virtual port and TCP connection.
//
// The local virtual port is opened using exactly the port number specified
// in virtualPortConfig.Port. This preserves the user's configured mapping
// between local virtual interfaces and remote devices.
//
// If StartupBytes are configured, they are sent once immediately after the
// TCP data connection has been established. Invalid startup bytes do not
// abort the connection setup. A warning is printed and the connection
// continues normally.
func connectVirtualPort(
c *Client,
manager *VirtualPortManager,
virtualPortConfig config.VirtualPortConfig,
devices []transport.RemoteDeviceInfo,
) (*ManagedVirtualPort, net.Conn, error) {
if c == nil || c.conn == nil {
return nil, nil, fmt.Errorf("client is not connected")
}
if manager == nil {
return nil, nil, fmt.Errorf("virtual port manager is nil")
}
device, err := findRemoteDevice(virtualPortConfig, devices)
if err != nil {
return nil, nil, err
}
portNumber, err := virtualPortNumber(virtualPortConfig.Port)
if err != nil {
return nil, nil, err
}
virtualPort, err := manager.OpenSpecific(portNumber)
if err != nil {
return nil, nil, fmt.Errorf(
"open virtual port %q for %q: %w",
virtualPortConfig.Port,
virtualPortConfig.RemoteDevice,
err,
)
}
conn, err := c.OpenDataConnection(device)
if err != nil {
_ = virtualPort.Close()
return nil, nil, fmt.Errorf(
"open data connection for %q: %w",
device.ID,
err,
)
}
if err := sendStartupBytes(conn, virtualPortConfig); err != nil {
fmt.Printf(
"warning: startup bytes for %q were not sent: %v\n",
virtualPortConfig.RemoteDevice,
err,
)
}
return virtualPort, conn, nil
}
// sendStartupBytes sends the optional configured startup byte sequence over
// the already established TCP data connection.
//
// An empty StartupBytes configuration does not send anything and is not an
// error.
//
// The configured value may contain spaces between hexadecimal bytes, for
// example:
//
// 57 00 00 00 00 00 00 00 00 00 00 1F 20
func sendStartupBytes(
conn net.Conn,
virtualPortConfig config.VirtualPortConfig,
) error {
startupBytes := strings.TrimSpace(virtualPortConfig.StartupBytes)
if startupBytes == "" {
return nil
}
hexText := strings.Join(strings.Fields(startupBytes), "")
data, err := hex.DecodeString(hexText)
if err != nil {
return fmt.Errorf("invalid hexadecimal byte sequence: %w", err)
}
if len(data) == 0 {
return nil
}
written, err := conn.Write(data)
if err != nil {
return fmt.Errorf("send startup bytes: %w", err)
}
if written != len(data) {
return fmt.Errorf(
"send startup bytes: wrote %d of %d bytes",
written,
len(data),
)
}
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Bridge connection
///////////////////////////////////////////////////////////////////////////////
// connectBridge creates the local virtual port, opens the corresponding
// remote TCP data connection and creates the data bridge.
//
// The caller owns the returned bridge and its endpoints.
func connectBridge(
c *Client,
manager *VirtualPortManager,
virtualPortConfig config.VirtualPortConfig,
devices []transport.RemoteDeviceInfo,
) (*Bridge, *ManagedVirtualPort, net.Conn, error) {
virtualPort, conn, err := connectVirtualPort(
c,
manager,
virtualPortConfig,
devices,
)
if err != nil {
return nil, nil, nil, err
}
bridge, err := NewBridge(virtualPort, conn)
if err != nil {
_ = conn.Close()
_ = virtualPort.Close()
return nil, nil, nil, fmt.Errorf(
"create bridge for %q: %w",
virtualPortConfig.RemoteDevice,
err,
)
}
return bridge, virtualPort, conn, nil
}

View file

@ -0,0 +1,525 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/connection_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für den Aufbau einer Verbindung zwischen einem lokalen virtuellen
* seriellen Port und dem zugehörigen entfernten TCP-Gerät.
*
* Die Tests prüfen insbesondere die Auswahl des Remote-Geräts, die Erzeugung
* des virtuellen Ports, den Aufbau der TCP-Datenverbindung und die Verbindung
* dieser Endpunkte über eine Bridge.
* ============================================================================
*/
package client
import (
"net"
"strconv"
"strings"
"testing"
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
///////////////////////////////////////////////////////////////////////////////
// Test connection
///////////////////////////////////////////////////////////////////////////////
// testRemoteAddrConn wraps a net.Conn and provides a TCP-style remote address.
//
// net.Pipe() is used for the control connection in these tests. Its default
// RemoteAddr() is "pipe", which cannot be used by OpenDataConnection() to
// determine the server host. This wrapper supplies a normal TCP address.
type testRemoteAddrConn struct {
net.Conn
remoteAddr net.Addr
}
// RemoteAddr returns the TCP-style remote address configured for the test.
func (c *testRemoteAddrConn) RemoteAddr() net.Addr {
return c.remoteAddr
}
///////////////////////////////////////////////////////////////////////////////
// Test helpers
///////////////////////////////////////////////////////////////////////////////
// startTestDataServer starts a local TCP listener for a simulated remote
// device.
//
// The listener accepts exactly one connection. The accepted connection is
// returned through the channel so the test can verify that the client has
// successfully established the data connection.
func startTestDataServer(t *testing.T) (string, <-chan net.Conn) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen for test data server: %v", err)
}
t.Cleanup(func() {
_ = listener.Close()
})
accepted := make(chan net.Conn, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
accepted <- conn
}()
return listener.Addr().String(), accepted
}
// remoteDeviceFromAddress creates a RemoteDeviceInfo whose DataPort is
// taken from the supplied local TCP address.
func remoteDeviceFromAddress(
t *testing.T,
id string,
address string,
) transport.RemoteDeviceInfo {
t.Helper()
_, portText, err := net.SplitHostPort(address)
if err != nil {
t.Fatalf("split test server address %q: %v", address, err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatalf("parse test server port %q: %v", portText, err)
}
return transport.RemoteDeviceInfo{
ID: id,
Name: id,
DataPort: port,
}
}
// newTestClient creates a Client whose control connection uses net.Pipe()
// but reports a normal TCP-style remote address.
func newTestClient(t *testing.T) *Client {
t.Helper()
controlClient, serverConn := net.Pipe()
t.Cleanup(func() {
_ = controlClient.Close()
_ = serverConn.Close()
})
return &Client{
conn: &testRemoteAddrConn{
Conn: controlClient,
remoteAddr: &net.TCPAddr{
IP: net.ParseIP("127.0.0.1"),
Port: 5000,
},
},
}
}
///////////////////////////////////////////////////////////////////////////////
// Tests
///////////////////////////////////////////////////////////////////////////////
func TestConnectVirtualPortNilClient(t *testing.T) {
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 199,
},
)
cfg := config.VirtualPortConfig{
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
devices := []transport.RemoteDeviceInfo{
{
ID: "radio",
DataPort: 50123,
},
}
_, _, err := connectVirtualPort(
nil,
manager,
cfg,
devices,
)
if err == nil {
t.Fatal("expected error for nil client")
}
if !strings.Contains(err.Error(), "client is not connected") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestConnectVirtualPortNilManager(t *testing.T) {
cfg := config.VirtualPortConfig{
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
devices := []transport.RemoteDeviceInfo{
{
ID: "radio",
DataPort: 50123,
},
}
_, _, err := connectVirtualPort(
&Client{},
nil,
cfg,
devices,
)
if err == nil {
t.Fatal("expected error for nil manager")
}
if !strings.Contains(err.Error(), "client is not connected") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestConnectVirtualPortUnknownDevice(t *testing.T) {
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 199,
},
)
client := newTestClient(t)
cfg := config.VirtualPortConfig{
Port: virtualPortPath(100),
RemoteDevice: "unknown",
}
devices := []transport.RemoteDeviceInfo{
{
ID: "radio",
DataPort: 50123,
},
}
_, _, err := connectVirtualPort(
client,
manager,
cfg,
devices,
)
if err == nil {
t.Fatal("expected error for unknown device")
}
if !strings.Contains(
err.Error(),
`remote device "unknown" not found`,
) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestConnectVirtualPortClosesPortWhenDataConnectionFails(t *testing.T) {
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 100,
},
)
client := newTestClient(t)
cfg := config.VirtualPortConfig{
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
devices := []transport.RemoteDeviceInfo{
{
ID: "radio",
DataPort: 1,
},
}
port, conn, err := connectVirtualPort(
client,
manager,
cfg,
devices,
)
if err == nil {
if conn != nil {
_ = conn.Close()
}
if port != nil {
_ = port.Close()
}
t.Fatal("expected data connection error")
}
if port != nil {
t.Fatal(
"virtual port must not be returned after connection failure",
)
}
if conn != nil {
t.Fatal(
"TCP connection must not be returned after connection failure",
)
}
// Port 100 must have been released after the failed connection.
portNumber, err := manager.Reserve()
if err != nil {
t.Fatalf("Reserve after cleanup: %v", err)
}
if portNumber != 100 {
t.Fatalf("reserved port = %d, want 100", portNumber)
}
manager.Release(portNumber)
}
func TestConnectVirtualPortSuccess(t *testing.T) {
dataAddress, accepted := startTestDataServer(t)
device := remoteDeviceFromAddress(
t,
"radio",
dataAddress,
)
client := newTestClient(t)
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 199,
},
)
cfg := config.VirtualPortConfig{
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
virtualPort, dataConn, err := connectVirtualPort(
client,
manager,
cfg,
[]transport.RemoteDeviceInfo{device},
)
if err != nil {
t.Fatalf("connectVirtualPort: %v", err)
}
if virtualPort == nil {
t.Fatal("virtual port is nil")
}
if dataConn == nil {
t.Fatal("data connection is nil")
}
defer virtualPort.Close()
defer dataConn.Close()
if virtualPort.Path() != virtualPortPath(100) {
t.Fatalf(
"virtual port path = %q, want %q",
virtualPort.Path(),
virtualPortPath(100),
)
}
// The test data server must receive the connection created by
// OpenDataConnection().
select {
case conn := <-accepted:
_ = conn.Close()
case <-t.Context().Done():
t.Fatal("test context cancelled while waiting for data connection")
}
}
func TestConnectVirtualPortUsesConfiguredPort(t *testing.T) {
dataAddress, accepted := startTestDataServer(t)
device := remoteDeviceFromAddress(
t,
"radio",
dataAddress,
)
client := newTestClient(t)
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 199,
},
)
cfg := config.VirtualPortConfig{
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
virtualPort, dataConn, err := connectVirtualPort(
client,
manager,
cfg,
[]transport.RemoteDeviceInfo{device},
)
if err != nil {
t.Fatalf("connectVirtualPort: %v", err)
}
if virtualPort == nil {
t.Fatal("virtual port is nil")
}
if dataConn == nil {
t.Fatal("data connection is nil")
}
defer virtualPort.Close()
defer dataConn.Close()
if virtualPort.Path() != virtualPortPath(100) {
t.Fatalf(
"virtual port path = %q, want %q",
virtualPort.Path(),
virtualPortPath(100),
)
}
// Port 100 is the configured port and therefore must be reserved.
if !manager.used[100] {
t.Fatal("configured port 100 is not reserved")
}
// Port 101 must still be available. This proves that the connection
// did not simply take another free port.
number, err := manager.Reserve()
if err != nil {
t.Fatalf("Reserve() after connection: %v", err)
}
if number != 101 {
t.Fatalf(
"first free port after connection = %d, want 101",
number,
)
}
manager.Release(number)
select {
case conn := <-accepted:
_ = conn.Close()
case <-t.Context().Done():
t.Fatal("test context cancelled while waiting for data connection")
}
}
func TestConnectBridgeSuccess(t *testing.T) {
dataAddress, accepted := startTestDataServer(t)
device := remoteDeviceFromAddress(
t,
"radio",
dataAddress,
)
client := newTestClient(t)
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 199,
},
)
cfg := config.VirtualPortConfig{
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
bridge, virtualPort, dataConn, err := connectBridge(
client,
manager,
cfg,
[]transport.RemoteDeviceInfo{device},
)
if err != nil {
t.Fatalf("connectBridge: %v", err)
}
if bridge == nil {
t.Fatal("bridge is nil")
}
if virtualPort == nil {
t.Fatal("virtual port is nil")
}
if dataConn == nil {
t.Fatal("data connection is nil")
}
defer virtualPort.Close()
defer dataConn.Close()
if bridge.serial != virtualPort {
t.Fatal("bridge serial endpoint does not match virtual port")
}
if bridge.conn != dataConn {
t.Fatal("bridge TCP endpoint does not match data connection")
}
if virtualPort.Path() != virtualPortPath(100) {
t.Fatalf(
"virtual port path = %q, want %q",
virtualPort.Path(),
virtualPortPath(100),
)
}
// The test data server must receive the TCP data connection created by
// connectBridge().
select {
case conn := <-accepted:
_ = conn.Close()
case <-t.Context().Done():
t.Fatal("test context cancelled while waiting for data connection")
}
}

View file

@ -1,7 +1,7 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: control.go
* Datei.......: internal/client/control.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
@ -18,6 +18,7 @@ import (
"bufio"
"fmt"
"net"
"sync"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
@ -28,6 +29,8 @@ import (
// Client represents a connection to an rs2322tcp server.
type Client struct {
mu sync.Mutex
conn net.Conn
reader *bufio.Reader
}
@ -79,6 +82,9 @@ func (c *Client) Conn() net.Conn {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
return c.conn
}
@ -87,12 +93,21 @@ func (c *Client) Conn() net.Conn {
///////////////////////////////////////////////////////////////////////////////
func (c *Client) hello() error {
if c == nil || c.conn == nil {
if c == nil {
return fmt.Errorf("client is not connected")
}
c.mu.Lock()
conn := c.conn
reader := c.reader
c.mu.Unlock()
if conn == nil {
return fmt.Errorf("client is not connected")
}
if err := transport.WriteMessage(
c.conn,
conn,
transport.NewHello(),
); err != nil {
return fmt.Errorf("send hello: %w", err)
@ -101,7 +116,7 @@ func (c *Client) hello() error {
var response transport.HelloResponseMessage
if err := transport.ReadMessage(
c.reader,
reader,
&response,
); err != nil {
return fmt.Errorf("read hello response: %w", err)
@ -125,24 +140,39 @@ func (c *Client) hello() error {
//
// The returned list contains the session-specific dynamic TCP data ports.
func (c *Client) GetDevices() ([]transport.RemoteDeviceInfo, error) {
if c == nil || c.conn == nil {
if c == nil {
return nil, fmt.Errorf("client is not connected")
}
c.mu.Lock()
conn := c.conn
reader := c.reader
c.mu.Unlock()
if conn == nil {
return nil, fmt.Errorf("client is not connected")
}
if err := transport.WriteMessage(
c.conn,
conn,
transport.NewGetDevices(),
); err != nil {
return nil, fmt.Errorf("send get-devices request: %w", err)
return nil, fmt.Errorf(
"send get-devices request: %w",
err,
)
}
var response transport.DeviceListMessage
if err := transport.ReadMessage(
c.reader,
reader,
&response,
); err != nil {
return nil, fmt.Errorf("read device list: %w", err)
return nil, fmt.Errorf(
"read device list: %w",
err,
)
}
if response.Type != transport.MessageDeviceList {
@ -160,13 +190,25 @@ func (c *Client) GetDevices() ([]transport.RemoteDeviceInfo, error) {
///////////////////////////////////////////////////////////////////////////////
// Close closes the client control connection.
//
// The connection is removed from the Client while holding the mutex.
// The actual network close is performed afterwards so that another caller
// cannot race with the state change.
func (c *Client) Close() error {
if c == nil || c.conn == nil {
if c == nil {
return nil
}
err := c.conn.Close()
c.mu.Lock()
conn := c.conn
c.conn = nil
return err
c.mu.Unlock()
if conn == nil {
return nil
}
return conn.Close()
}

70
internal/client/device.go Normal file
View file

@ -0,0 +1,70 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/device.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Zuordnung eines lokalen virtuellen Ports zu einem vom Server gemeldeten
* Remote-Gerät.
* ============================================================================
*/
package client
import (
"fmt"
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
///////////////////////////////////////////////////////////////////////////////
// Remote device lookup
///////////////////////////////////////////////////////////////////////////////
// findRemoteDevice finds the server-provided device information matching
// the remote device ID configured for a local virtual port.
//
// The caller uses the returned RemoteDeviceInfo to open the session-specific
// TCP data connection.
func findRemoteDevice(
virtualPort config.VirtualPortConfig,
devices []transport.RemoteDeviceInfo,
) (transport.RemoteDeviceInfo, error) {
if virtualPort.RemoteDevice == "" {
return transport.RemoteDeviceInfo{}, fmt.Errorf(
"remote device is empty for virtual port %q",
virtualPort.Port,
)
}
var found *transport.RemoteDeviceInfo
for i := range devices {
device := &devices[i]
if device.ID != virtualPort.RemoteDevice {
continue
}
if found != nil {
return transport.RemoteDeviceInfo{}, fmt.Errorf(
"remote device %q is ambiguous",
virtualPort.RemoteDevice,
)
}
found = device
}
if found == nil {
return transport.RemoteDeviceInfo{}, fmt.Errorf(
"remote device %q not found",
virtualPort.RemoteDevice,
)
}
return *found, nil
}

View file

@ -0,0 +1,159 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/device_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für die Zuordnung lokaler virtueller Ports zu den vom Server
* gemeldeten Remote-Geräten.
* ============================================================================
*/
package client
import (
"strings"
"testing"
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
///////////////////////////////////////////////////////////////////////////////
// Test data
///////////////////////////////////////////////////////////////////////////////
func testRemoteDevices() []transport.RemoteDeviceInfo {
return []transport.RemoteDeviceInfo{
{
ID: "radio",
Name: "Radio",
BaudRate: 9600,
DataBits: 8,
Parity: "none",
StopBits: 1,
DataPort: 50123,
},
{
ID: "rotor",
Name: "Rotor",
BaudRate: 4800,
DataBits: 8,
Parity: "none",
StopBits: 1,
DataPort: 50124,
},
}
}
///////////////////////////////////////////////////////////////////////////////
// Tests
///////////////////////////////////////////////////////////////////////////////
func TestFindRemoteDevice(t *testing.T) {
devices := testRemoteDevices()
virtualPort := config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
RemoteDevice: "radio",
}
got, err := findRemoteDevice(virtualPort, devices)
if err != nil {
t.Fatalf("findRemoteDevice: %v", err)
}
if got.ID != "radio" {
t.Fatalf("ID = %q, want %q", got.ID, "radio")
}
if got.DataPort != 50123 {
t.Fatalf("DataPort = %d, want %d", got.DataPort, 50123)
}
}
func TestFindRemoteDeviceRotor(t *testing.T) {
devices := testRemoteDevices()
virtualPort := config.VirtualPortConfig{
Port: "/dev/ttyUSB101",
RemoteDevice: "rotor",
}
got, err := findRemoteDevice(virtualPort, devices)
if err != nil {
t.Fatalf("findRemoteDevice: %v", err)
}
if got.ID != "rotor" {
t.Fatalf("ID = %q, want %q", got.ID, "rotor")
}
if got.DataPort != 50124 {
t.Fatalf("DataPort = %d, want %d", got.DataPort, 50124)
}
}
func TestFindRemoteDeviceEmptyID(t *testing.T) {
devices := testRemoteDevices()
virtualPort := config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
}
_, err := findRemoteDevice(virtualPort, devices)
if err == nil {
t.Fatal("expected error for empty remote device")
}
if !strings.Contains(err.Error(), "remote device is empty") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFindRemoteDeviceNotFound(t *testing.T) {
devices := testRemoteDevices()
virtualPort := config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
RemoteDevice: "unknown",
}
_, err := findRemoteDevice(virtualPort, devices)
if err == nil {
t.Fatal("expected error for unknown remote device")
}
if !strings.Contains(err.Error(), `remote device "unknown" not found`) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFindRemoteDeviceAmbiguous(t *testing.T) {
devices := []transport.RemoteDeviceInfo{
{
ID: "radio",
DataPort: 50123,
},
{
ID: "radio",
DataPort: 50125,
},
}
virtualPort := config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
RemoteDevice: "radio",
}
_, err := findRemoteDevice(virtualPort, devices)
if err == nil {
t.Fatal("expected error for ambiguous remote device")
}
if !strings.Contains(err.Error(), `remote device "radio" is ambiguous`) {
t.Fatalf("unexpected error: %v", err)
}
}

View file

@ -29,6 +29,16 @@ import (
"git.lang-dieter.de/rs2322tcp/internal/server"
)
///////////////////////////////////////////////////////////////////////////////
// Test configuration
///////////////////////////////////////////////////////////////////////////////
const testHardwareErrorResponse = "TEST ERROR RESPONSE"
///////////////////////////////////////////////////////////////////////////////
// Virtual serial pair
///////////////////////////////////////////////////////////////////////////////
func startVirtualSerialPair(t *testing.T) (string, string, func()) {
t.Helper()
@ -79,6 +89,10 @@ func startVirtualSerialPair(t *testing.T) (string, string, func()) {
}
}
///////////////////////////////////////////////////////////////////////////////
// Read helper
///////////////////////////////////////////////////////////////////////////////
func readExactWithTimeout(
t *testing.T,
reader io.Reader,
@ -105,6 +119,10 @@ func readExactWithTimeout(
}
}
///////////////////////////////////////////////////////////////////////////////
// Integration test
///////////////////////////////////////////////////////////////////////////////
func TestClientServerSerialIntegration(t *testing.T) {
serialA, serialB, cleanup := startVirtualSerialPair(t)
defer cleanup()
@ -130,6 +148,7 @@ func TestClientServerSerialIntegration(t *testing.T) {
Address: "127.0.0.1",
Port: controlPort,
},
HardwareErrorResponse: testHardwareErrorResponse,
Devices: []config.DeviceConfig{
{
ID: "radio",

View file

@ -1,19 +1,51 @@
//go:build linux
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/pty_linux.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Linux-spezifische Implementierung der virtuellen seriellen Schnittstelle
* über ein PTY-Paar.
*
* Der PTY-Master wird intern vom Client verwendet. Der zugehörige PTY-Slave
* wird über den virtuellen Port als serielle Schnittstelle bereitgestellt.
* ============================================================================
*/
package client
import (
"fmt"
"sync/atomic"
"golang.org/x/sys/unix"
)
///////////////////////////////////////////////////////////////////////////////
// ptySerial
///////////////////////////////////////////////////////////////////////////////
// ptySerial implements VirtualSerial using a Linux PTY pair.
//
// The file descriptors are stored atomically because Read/Write may execute
// concurrently with Close. Close must be able to invalidate the descriptors
// without racing with a concurrent Read or Write.
type ptySerial struct {
masterFD int
slaveFD int
masterFD atomic.Int64
slaveFD atomic.Int64
path string
}
///////////////////////////////////////////////////////////////////////////////
// Constructor
///////////////////////////////////////////////////////////////////////////////
// newVirtualSerial creates a new Linux PTY pair.
func newVirtualSerial() (VirtualSerial, error) {
masterFD, err := unix.Open(
"/dev/ptmx",
@ -60,7 +92,11 @@ func newVirtualSerial() (VirtualSerial, error) {
0,
)
if err != nil {
return nil, fmt.Errorf("open PTY slave %s: %w", path, err)
return nil, fmt.Errorf(
"open PTY slave %s: %w",
path,
err,
)
}
closeSlave := true
@ -74,16 +110,23 @@ func newVirtualSerial() (VirtualSerial, error) {
return nil, err
}
serial := &ptySerial{
path: path,
}
serial.masterFD.Store(int64(masterFD))
serial.slaveFD.Store(int64(slaveFD))
closeMaster = false
closeSlave = false
return &ptySerial{
masterFD: masterFD,
slaveFD: slaveFD,
path: path,
}, nil
return serial, nil
}
///////////////////////////////////////////////////////////////////////////////
// PTY configuration
///////////////////////////////////////////////////////////////////////////////
func configurePTY(fd int) error {
termios, err := unix.IoctlGetTermios(
fd,
@ -134,33 +177,80 @@ func configurePTY(fd int) error {
return nil
}
///////////////////////////////////////////////////////////////////////////////
// VirtualSerial implementation
///////////////////////////////////////////////////////////////////////////////
// Path returns the PTY slave path.
func (p *ptySerial) Path() string {
return p.path
}
// Read reads raw bytes from the PTY master.
func (p *ptySerial) Read(b []byte) (int, error) {
return unix.Read(p.masterFD, b)
fd := int(p.masterFD.Load())
if fd < 0 {
return 0, fmt.Errorf("PTY master is closed")
}
return unix.Read(fd, b)
}
// Write writes raw bytes to the PTY master.
func (p *ptySerial) Write(b []byte) (int, error) {
return unix.Write(p.masterFD, b)
fd := int(p.masterFD.Load())
if fd < 0 {
return 0, fmt.Errorf("PTY master is closed")
}
fmt.Printf(
"PTY TX [%s]: % X\n",
p.path,
b,
)
n, err := unix.Write(fd, b)
if err != nil {
fmt.Printf(
"PTY TX Fehler [%s]: %v\n",
p.path,
err,
)
return n, err
}
fmt.Printf(
"PTY TX erfolgreich [%s]: %d Bytes\n",
p.path,
n,
)
return n, nil
}
// Close closes the PTY master and slave.
//
// The descriptors are invalidated atomically before closing them so that
// concurrent Read or Write calls do not access the descriptor fields
// concurrently with Close.
func (p *ptySerial) Close() error {
var firstErr error
if p.masterFD >= 0 {
if err := unix.Close(p.masterFD); err != nil {
masterFD := int(p.masterFD.Swap(-1))
if masterFD >= 0 {
if err := unix.Close(masterFD); err != nil {
firstErr = err
}
p.masterFD = -1
}
if p.slaveFD >= 0 {
if err := unix.Close(p.slaveFD); err != nil && firstErr == nil {
slaveFD := int(p.slaveFD.Swap(-1))
if slaveFD >= 0 {
if err := unix.Close(slaveFD); err != nil && firstErr == nil {
firstErr = err
}
p.slaveFD = -1
}
return firstErr

220
internal/client/runtime.go Normal file
View file

@ -0,0 +1,220 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/runtime.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Clientseitige Laufzeitverwaltung für die konfigurierten virtuellen
* seriellen Schnittstellen.
*
* Die Runtime verbindet die in der Client-Konfiguration angegebenen
* virtuellen Ports mit den zugehörigen entfernten Geräten.
*
* Für jedes konfigurierte virtuelle Gerät wird eine eigene Bridge gestartet:
*
* /dev/ttyUSBxxx Bridge TCP Data Connection
*
* Die Control-Verbindung zum Server wird gemeinsam von allen Bridges
* verwendet.
* ============================================================================
*/
package client
import (
"fmt"
"sync"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
///////////////////////////////////////////////////////////////////////////////
// Runtime
///////////////////////////////////////////////////////////////////////////////
// Runtime manages the client-side virtual serial connections.
//
// A Runtime owns the Client control connection and the VirtualPortManager.
// Each configured virtual port is represented by one running Bridge.
type Runtime struct {
client *Client
manager *VirtualPortManager
mu sync.Mutex
bridges []*Bridge
}
// NewRuntime creates a new client Runtime.
//
// The Runtime does not establish a server connection and does not open any
// virtual ports until Run is called.
func NewRuntime(
client *Client,
manager *VirtualPortManager,
) (*Runtime, error) {
if client == nil {
return nil, fmt.Errorf("client is nil")
}
if manager == nil {
return nil, fmt.Errorf("virtual port manager is nil")
}
return &Runtime{
client: client,
manager: manager,
}, nil
}
///////////////////////////////////////////////////////////////////////////////
// Run
///////////////////////////////////////////////////////////////////////////////
// Run starts all configured virtual serial connections.
//
// The supplied configuration determines which local virtual ports are
// assigned to which remote devices.
//
// Run blocks until one bridge terminates or an error occurs while creating
// one of the configured bridges.
func (r *Runtime) Run(
cfg config.ClientConfig,
) error {
if r == nil || r.client == nil {
return fmt.Errorf("runtime is not initialized")
}
if r.manager == nil {
return fmt.Errorf("virtual port manager is nil")
}
if len(cfg.VirtualPorts) == 0 {
return nil
}
devices, err := r.client.GetDevices()
if err != nil {
return fmt.Errorf("get remote devices: %w", err)
}
for _, virtualPortConfig := range cfg.VirtualPorts {
if virtualPortConfig.RemoteDevice == "" {
continue
}
bridge, _, _, err := connectBridge(
r.client,
r.manager,
virtualPortConfig,
devices,
)
if err != nil {
r.Close()
return err
}
r.mu.Lock()
r.bridges = append(r.bridges, bridge)
r.mu.Unlock()
}
return r.waitForBridge()
}
///////////////////////////////////////////////////////////////////////////////
// Bridge lifecycle
///////////////////////////////////////////////////////////////////////////////
// waitForBridge waits until one of the running bridges terminates.
//
// A bridge terminating is considered the end of the Runtime. The Bridge
// itself closes its serial and TCP endpoints when Run returns.
func (r *Runtime) waitForBridge() error {
results := make(chan error, len(r.bridges))
r.mu.Lock()
bridges := append([]*Bridge(nil), r.bridges...)
r.mu.Unlock()
for _, bridge := range bridges {
go func(b *Bridge) {
results <- b.Run()
}(bridge)
}
err := <-results
r.Close()
return err
}
///////////////////////////////////////////////////////////////////////////////
// Close
///////////////////////////////////////////////////////////////////////////////
// Close closes all resources owned by the Runtime.
//
// Closing the bridge endpoints causes running Bridge.Run calls to terminate.
// The client control connection is closed afterwards.
//
// Calling Close more than once is safe.
func (r *Runtime) Close() error {
if r == nil {
return nil
}
r.mu.Lock()
bridges := append([]*Bridge(nil), r.bridges...)
r.bridges = nil
r.mu.Unlock()
var firstErr error
for _, bridge := range bridges {
if bridge == nil {
continue
}
if bridge.serial != nil {
if err := bridge.serial.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
if bridge.conn != nil {
if err := bridge.conn.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
}
if r.client != nil {
if err := r.client.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// Client returns the Client used by the Runtime.
func (r *Runtime) Client() *Client {
if r == nil {
return nil
}
return r.client
}
// Manager returns the VirtualPortManager used by the Runtime.
func (r *Runtime) Manager() *VirtualPortManager {
if r == nil {
return nil
}
return r.manager
}

View file

@ -0,0 +1,281 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/runtime_integration_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Integrationstest für die clientseitige Runtime mit einem echten
* rs2322tcp-Server und einem virtuellen seriellen Client-Port.
*
* Der Test verwendet die vorhandene virtuelle serielle Test-Schnittstelle
* aus integration_test.go und prüft den vollständigen Datenweg:
*
* /dev/ttyUSB100
*
* Bridge
*
* TCP Data Connection
*
* rs2322tcp Server
*
* serielle PTY
*
* Beide Übertragungsrichtungen werden geprüft.
* ============================================================================
*/
package client_test
import (
"net"
"os"
"path/filepath"
"testing"
"time"
"git.lang-dieter.de/rs2322tcp/internal/client"
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/server"
)
///////////////////////////////////////////////////////////////////////////////
// Tests
///////////////////////////////////////////////////////////////////////////////
func TestClientRuntimeSerialIntegration(t *testing.T) {
serialA, serialB, cleanup := startVirtualSerialPair(t)
defer cleanup()
const virtualPortPath = "/dev/ttyUSB100"
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("get user home directory: %v", err)
}
internalPortPath := filepath.Join(
home,
".rs2322tcp",
"virtual",
"ttyUSB100",
)
////////////////////////////////////////////////////////////////////////////
// Server
////////////////////////////////////////////////////////////////////////////
controlProbe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("find free control port: %v", err)
}
controlPort := controlProbe.Addr().(*net.TCPAddr).Port
if err := controlProbe.Close(); err != nil {
t.Fatalf("close control probe: %v", err)
}
serverConfig := &config.ServerConfig{
Listen: config.ListenConfig{
Address: "127.0.0.1",
Port: controlPort,
},
HardwareErrorResponse: testHardwareErrorResponse,
Devices: []config.DeviceConfig{
{
ID: "radio",
Name: "Funkgerät",
SerialPort: serialA,
BaudRate: 9600,
DataBits: 8,
Parity: "none",
StopBits: 1,
},
},
}
srv, err := server.NewControlServer(serverConfig)
if err != nil {
t.Fatalf("NewControlServer() failed: %v", err)
}
if err := srv.Listen(); err != nil {
t.Fatalf("server Listen() failed: %v", err)
}
defer srv.Close()
go func() {
_ = srv.Serve()
}()
////////////////////////////////////////////////////////////////////////////
// Client and Runtime
////////////////////////////////////////////////////////////////////////////
c, err := client.New(srv.Addr().String())
if err != nil {
t.Fatalf("client.New() failed: %v", err)
}
defer c.Close()
manager := client.NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 199,
},
)
runtime, err := client.NewRuntime(c, manager)
if err != nil {
t.Fatalf("NewRuntime() failed: %v", err)
}
defer runtime.Close()
clientConfig := config.ClientConfig{
Server: config.ServerConnectionConfig{
Address: "127.0.0.1",
Port: controlPort,
},
VirtualPorts: []config.VirtualPortConfig{
{
Port: virtualPortPath,
RemoteDevice: "radio",
},
},
}
////////////////////////////////////////////////////////////////////////////
// Start Runtime
////////////////////////////////////////////////////////////////////////////
runtimeDone := make(chan error, 1)
go func() {
runtimeDone <- runtime.Run(clientConfig)
}()
////////////////////////////////////////////////////////////////////////////
// Open client-side virtual serial endpoint
////////////////////////////////////////////////////////////////////////////
var virtualSerial *os.File
deadline := time.Now().Add(2 * time.Second)
for {
virtualSerial, err = os.OpenFile(
internalPortPath,
os.O_RDWR,
0,
)
if err == nil {
break
}
if time.Now().After(deadline) {
t.Fatalf(
"timeout opening virtual serial port %s: %v",
internalPortPath,
err,
)
}
time.Sleep(10 * time.Millisecond)
}
defer virtualSerial.Close()
////////////////////////////////////////////////////////////////////////////
// Client virtual serial -> Bridge -> Server -> serial
////////////////////////////////////////////////////////////////////////////
clientMessage := []byte("hello from runtime")
if _, err := virtualSerial.Write(clientMessage); err != nil {
t.Fatalf(
"write to virtual serial port failed: %v",
err,
)
}
serialReceived := make([]byte, len(clientMessage))
serialPeer, err := os.OpenFile(
serialB,
os.O_RDWR,
0,
)
if err != nil {
t.Fatalf(
"open server serial peer: %v",
err,
)
}
defer serialPeer.Close()
readExactWithTimeout(
t,
serialPeer,
serialReceived,
2*time.Second,
)
if string(serialReceived) != string(clientMessage) {
t.Fatalf(
"serial received %q, want %q",
string(serialReceived),
string(clientMessage),
)
}
////////////////////////////////////////////////////////////////////////////
// Server serial -> Bridge -> Client virtual serial
////////////////////////////////////////////////////////////////////////////
serverMessage := []byte("hello from server")
if _, err := serialPeer.Write(serverMessage); err != nil {
t.Fatalf(
"write to server serial peer failed: %v",
err,
)
}
clientReceived := make([]byte, len(serverMessage))
readExactWithTimeout(
t,
virtualSerial,
clientReceived,
2*time.Second,
)
if string(clientReceived) != string(serverMessage) {
t.Fatalf(
"virtual serial received %q, want %q",
string(clientReceived),
string(serverMessage),
)
}
///////////////////////////////////////////////////////////////////////////////
// Terminate Runtime
///////////////////////////////////////////////////////////////////////////////
if err := virtualSerial.Close(); err != nil {
t.Fatalf("close virtual serial port: %v", err)
}
if err := runtime.Close(); err != nil {
t.Fatalf("close runtime: %v", err)
}
select {
case <-runtimeDone:
// Runtime termination is expected after Runtime.Close().
case <-time.After(2 * time.Second):
t.Fatal("runtime did not terminate after Runtime.Close()")
}
}

View file

@ -0,0 +1,82 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/runtime_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für die clientseitige Runtime.
*
* Die Tests prüfen die Initialisierung der Runtime und den grundlegenden
* Lebenszyklus ohne einen externen rs2322tcp-Server vorauszusetzen.
* ============================================================================
*/
package client
import (
"strings"
"testing"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
///////////////////////////////////////////////////////////////////////////////
// Tests
///////////////////////////////////////////////////////////////////////////////
func TestNewRuntimeNilClient(t *testing.T) {
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 199,
},
)
_, err := NewRuntime(nil, manager)
if err == nil {
t.Fatal("expected error for nil client")
}
if !strings.Contains(err.Error(), "client is nil") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestNewRuntimeNilManager(t *testing.T) {
client := newTestClient(t)
_, err := NewRuntime(client, nil)
if err == nil {
t.Fatal("expected error for nil manager")
}
if !strings.Contains(err.Error(), "virtual port manager is nil") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRuntimeRunWithoutVirtualPorts(t *testing.T) {
client := newTestClient(t)
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 199,
},
)
runtime, err := NewRuntime(client, manager)
if err != nil {
t.Fatalf("NewRuntime: %v", err)
}
cfg := config.ClientConfig{
VirtualPorts: nil,
}
if err := runtime.Run(cfg); err != nil {
t.Fatalf("Run: %v", err)
}
}

View file

@ -0,0 +1,70 @@
/*
Package client contains the client-side components of rs2322tcp.
Project: rs2322tcp
Module: git.lang-dieter.de/rs2322tcp
*/
package client
import "io"
///////////////////////////////////////////////////////////////////////////////
// Virtual port
///////////////////////////////////////////////////////////////////////////////
// VirtualPort represents one local virtual serial port.
//
// The visible path is the device name presented to the external application,
// for example /dev/ttyUSB100.
//
// The underlying VirtualSerial remains an internal implementation detail and
// may use a PTY such as /dev/pts/2.
type VirtualPort struct {
path string
serial VirtualSerial
}
// NewVirtualPort creates a virtual port with the specified visible path and
// underlying virtual serial device.
func NewVirtualPort(path string, serial VirtualSerial) *VirtualPort {
return &VirtualPort{
path: path,
serial: serial,
}
}
// Path returns the device path visible to the external application.
func (p *VirtualPort) Path() string {
if p == nil {
return ""
}
return p.path
}
// Read reads data from the underlying virtual serial device.
func (p *VirtualPort) Read(b []byte) (int, error) {
if p == nil || p.serial == nil {
return 0, io.ErrClosedPipe
}
return p.serial.Read(b)
}
// Write writes data to the underlying virtual serial device.
func (p *VirtualPort) Write(b []byte) (int, error) {
if p == nil || p.serial == nil {
return 0, io.ErrClosedPipe
}
return p.serial.Write(b)
}
// Close closes the underlying virtual serial device.
func (p *VirtualPort) Close() error {
if p == nil || p.serial == nil {
return nil
}
return p.serial.Close()
}

View file

@ -0,0 +1,149 @@
//go:build linux
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_linux.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Linux-spezifische Verwaltung der internen Symlinks für virtuelle
* rs2322tcp-Seriellschnittstellen.
*
* Der öffentliche Gerätename /dev/ttyUSBxxx wird bei der Installation
* vorbereitet. Der Client verwaltet ausschließlich den benutzerbezogenen
* Link unter ~/.rs2322tcp/virtual/.
* ============================================================================
*/
package client
import (
"fmt"
"os"
"path/filepath"
)
const (
virtualDirectoryName = ".rs2322tcp/virtual"
)
// virtualPortDirectory returns the directory in the current user's home
// directory used for the internal virtual-port links.
func virtualPortDirectory() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("get user home directory: %w", err)
}
return filepath.Join(home, virtualDirectoryName), nil
}
// virtualPortLinkPath returns the path of the internal link for a virtual
// serial port.
func virtualPortLinkPath(portPath string) (string, error) {
directory, err := virtualPortDirectory()
if err != nil {
return "", err
}
return filepath.Join(directory, filepath.Base(portPath)), nil
}
// ensureVirtualPortDirectory creates the internal virtual-port directory
// if it does not already exist.
func ensureVirtualPortDirectory() error {
directory, err := virtualPortDirectory()
if err != nil {
return err
}
if err := os.MkdirAll(directory, 0755); err != nil {
return fmt.Errorf(
"create virtual port directory %q: %w",
directory,
err,
)
}
return nil
}
// setVirtualPortLink creates or replaces the internal link for a virtual
// port.
//
// The link itself is located below ~/.rs2322tcp/virtual/ and points to the
// current PTY, for example /dev/pts/2.
//
// The public /dev/ttyUSBxxx installation link is deliberately never
// modified here.
func setVirtualPortLink(portPath string, target string) error {
if portPath == "" {
return fmt.Errorf("virtual port path is empty")
}
if target == "" {
return fmt.Errorf("virtual port target is empty")
}
if err := ensureVirtualPortDirectory(); err != nil {
return err
}
linkPath, err := virtualPortLinkPath(portPath)
if err != nil {
return err
}
// Replace only the user-owned internal link.
//
// The installation-owned /dev/ttyUSBxxx link is deliberately never
// touched here.
if err := os.Remove(linkPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf(
"remove existing virtual port link %q: %w",
linkPath,
err,
)
}
if err := os.Symlink(target, linkPath); err != nil {
return fmt.Errorf(
"create virtual port link %q -> %q: %w",
linkPath,
target,
err,
)
}
return nil
}
// removeVirtualPortLink removes the user-owned internal link for a virtual
// port.
//
// This function is retained for explicit link-management operations and
// tests. It is NOT called by ManagedVirtualPort.Close(), because the
// installation-owned symlink chain must remain available.
func removeVirtualPortLink(portPath string) error {
if portPath == "" {
return fmt.Errorf("virtual port path is empty")
}
linkPath, err := virtualPortLinkPath(portPath)
if err != nil {
return err
}
if err := os.Remove(linkPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf(
"remove existing virtual port link %q: %w",
linkPath,
err,
)
}
return nil
}

View file

@ -0,0 +1,348 @@
//go:build linux
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_linux_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für die Linux-spezifische Verwaltung der internen Symlinks
* virtueller rs2322tcp-Seriellschnittstellen.
* ============================================================================
*/
package client
import (
"os"
"path/filepath"
"reflect"
"testing"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
func TestVirtualPortLinkPath(t *testing.T) {
path, err := virtualPortLinkPath("/dev/ttyUSB100")
if err != nil {
t.Fatalf("virtualPortLinkPath() failed: %v", err)
}
wantSuffix := filepath.Join(
".rs2322tcp",
"virtual",
"ttyUSB100",
)
if !filepath.IsAbs(path) {
t.Fatalf("path is not absolute: %q", path)
}
if !hasPathSuffix(path, wantSuffix) {
t.Errorf(
"virtualPortLinkPath() = %q, want suffix %q",
path,
wantSuffix,
)
}
}
func TestSetAndRemoveVirtualPortLink(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
portPath := "/dev/ttyUSB100"
target := "/dev/pts/2"
if err := setVirtualPortLink(portPath, target); err != nil {
t.Fatalf("setVirtualPortLink() failed: %v", err)
}
linkPath, err := virtualPortLinkPath(portPath)
if err != nil {
t.Fatalf("virtualPortLinkPath() failed: %v", err)
}
got, err := os.Readlink(linkPath)
if err != nil {
t.Fatalf("Readlink() failed: %v", err)
}
if got != target {
t.Errorf(
"link target = %q, want %q",
got,
target,
)
}
if err := removeVirtualPortLink(portPath); err != nil {
t.Fatalf("removeVirtualPortLink() failed: %v", err)
}
if _, err := os.Lstat(linkPath); !os.IsNotExist(err) {
t.Fatalf(
"link still exists after removal: %q",
linkPath,
)
}
}
func TestSetVirtualPortLinkReplacesExistingLink(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
portPath := "/dev/ttyUSB100"
if err := setVirtualPortLink(
portPath,
"/dev/pts/2",
); err != nil {
t.Fatalf(
"first setVirtualPortLink() failed: %v",
err,
)
}
if err := setVirtualPortLink(
portPath,
"/dev/pts/7",
); err != nil {
t.Fatalf(
"second setVirtualPortLink() failed: %v",
err,
)
}
linkPath, err := virtualPortLinkPath(portPath)
if err != nil {
t.Fatalf("virtualPortLinkPath() failed: %v", err)
}
got, err := os.Readlink(linkPath)
if err != nil {
t.Fatalf("Readlink() failed: %v", err)
}
if got != "/dev/pts/7" {
t.Errorf(
"link target = %q, want %q",
got,
"/dev/pts/7",
)
}
}
func TestRemoveVirtualPortLinkMissing(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
if err := removeVirtualPortLink("/dev/ttyUSB100"); err != nil {
t.Fatalf(
"removeVirtualPortLink() failed for missing link: %v",
err,
)
}
}
func TestVirtualPortLinkPathUsesBaseName(t *testing.T) {
path, err := virtualPortLinkPath("/dev/ttyUSB123")
if err != nil {
t.Fatalf("virtualPortLinkPath() failed: %v", err)
}
if filepath.Base(path) != "ttyUSB123" {
t.Errorf(
"filepath.Base() = %q, want %q",
filepath.Base(path),
"ttyUSB123",
)
}
}
func TestExistingVirtualPortLinksNone(t *testing.T) {
tempDir := t.TempDir()
cfg := config.VirtualPortRangeConfig{
First: 100,
Last: 102,
}
got, err := existingVirtualPortLinksInDirectory(
cfg,
tempDir,
)
if err != nil {
t.Fatalf(
"existingVirtualPortLinksInDirectory() failed: %v",
err,
)
}
if len(got) != 0 {
t.Fatalf(
"existingVirtualPortLinksInDirectory() = %v, want empty list",
got,
)
}
}
func TestExistingVirtualPortLinksFindsSymlinks(t *testing.T) {
tempDir := t.TempDir()
cfg := config.VirtualPortRangeConfig{
First: 100,
Last: 102,
}
for _, number := range []int{100, 102} {
path := filepath.Join(
tempDir,
"ttyUSB"+itoa(number),
)
if err := os.Symlink(
"/dev/pts/"+itoa(number),
path,
); err != nil {
t.Fatalf(
"create test symlink %q: %v",
path,
err,
)
}
}
got, err := existingVirtualPortLinksInDirectory(
cfg,
tempDir,
)
if err != nil {
t.Fatalf(
"existingVirtualPortLinksInDirectory() failed: %v",
err,
)
}
want := []string{
filepath.Join(tempDir, "ttyUSB100"),
filepath.Join(tempDir, "ttyUSB102"),
}
if !reflect.DeepEqual(got, want) {
t.Fatalf(
"existingVirtualPortLinksInDirectory() = %v, want %v",
got,
want,
)
}
}
func TestExistingVirtualPortLinksIgnoresRegularFile(t *testing.T) {
tempDir := t.TempDir()
cfg := config.VirtualPortRangeConfig{
First: 100,
Last: 102,
}
path := filepath.Join(tempDir, "ttyUSB100")
if err := os.WriteFile(
path,
[]byte("not a symlink"),
0644,
); err != nil {
t.Fatalf(
"create regular test file %q: %v",
path,
err,
)
}
got, err := existingVirtualPortLinksInDirectory(
cfg,
tempDir,
)
if err != nil {
t.Fatalf(
"existingVirtualPortLinksInDirectory() failed: %v",
err,
)
}
if len(got) != 0 {
t.Fatalf(
"regular file was detected as virtual port: %v",
got,
)
}
}
func TestExistingVirtualPortLinksIgnoresOutsideRange(t *testing.T) {
tempDir := t.TempDir()
cfg := config.VirtualPortRangeConfig{
First: 100,
Last: 102,
}
path := filepath.Join(tempDir, "ttyUSB103")
if err := os.Symlink(
"/dev/pts/103",
path,
); err != nil {
t.Fatalf(
"create outside-range symlink %q: %v",
path,
err,
)
}
got, err := existingVirtualPortLinksInDirectory(
cfg,
tempDir,
)
if err != nil {
t.Fatalf(
"existingVirtualPortLinksInDirectory() failed: %v",
err,
)
}
if len(got) != 0 {
t.Fatalf(
"outside-range symlink was detected: %v",
got,
)
}
}
func itoa(number int) string {
const digits = "0123456789"
if number == 0 {
return "0"
}
result := ""
for number > 0 {
result = string(digits[number%10]) + result
number /= 10
}
return result
}
func hasPathSuffix(path string, suffix string) bool {
return path == suffix ||
len(path) > len(suffix) &&
path[len(path)-len(suffix):] == suffix
}

View file

@ -0,0 +1,183 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_manager.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Verwaltung und Auswahl der für rs2322tcp reservierten virtuellen
* seriellen Ports.
*
* Der VirtualPortManager verwaltet den für rs2322tcp vorgesehenen Bereich
* virtueller serieller Schnittstellen.
*
* Die konkrete Darstellung eines virtuellen Ports ist plattformabhängig.
* Unter Linux werden beispielsweise /dev/ttyUSBxxx-Schnittstellen verwendet,
* während unter Windows COMxxx-Schnittstellen verwendet werden.
*
* Die eigentliche Erzeugung beziehungsweise Bereitstellung des seriellen
* Ports erfolgt ebenfalls plattformabhängig und ist nicht Bestandteil dieses
* Managers.
*
* Ports können entweder automatisch über den nächsten freien Port oder
* gezielt über eine vorgegebene Portnummer reserviert werden.
* ============================================================================
*/
package client
import (
"fmt"
"sync"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
///////////////////////////////////////////////////////////////////////////////
// VirtualPortManager
///////////////////////////////////////////////////////////////////////////////
// VirtualPortManager verwaltet den für rs2322tcp reservierten Bereich
// virtueller serieller Schnittstellen.
//
// Der reservierte Portbereich und die Belegungstabelle werden durch einen
// Mutex geschützt, da mehrere Bridges beziehungsweise Goroutinen gleichzeitig
// virtuelle Ports öffnen und freigeben können.
//
// Die konkrete Darstellung des sichtbaren Portnamens wird über
// virtualPortPath() von der jeweiligen Plattform bestimmt.
type VirtualPortManager struct {
mu sync.Mutex
first int
last int
used map[int]bool
}
///////////////////////////////////////////////////////////////////////////////
// Constructor
///////////////////////////////////////////////////////////////////////////////
// NewVirtualPortManager erzeugt einen VirtualPortManager aus dem
// konfigurierten Portbereich.
func NewVirtualPortManager(
cfg config.VirtualPortRangeConfig,
) *VirtualPortManager {
return &VirtualPortManager{
first: cfg.First,
last: cfg.Last,
used: make(map[int]bool),
}
}
///////////////////////////////////////////////////////////////////////////////
// Port path
///////////////////////////////////////////////////////////////////////////////
// PortPath erzeugt den für die externe Software sichtbaren Gerätenamen
// für eine Portnummer.
//
// Die konkrete Darstellung ist plattformabhängig:
//
// Linux /dev/ttyUSB100
// Windows COM100
//
// Die Plattformimplementierung befindet sich in
// virtual_port_path_linux.go beziehungsweise virtual_port_path_windows.go.
func (m *VirtualPortManager) PortPath(number int) string {
if m == nil {
return ""
}
return virtualPortPath(number)
}
///////////////////////////////////////////////////////////////////////////////
// Reservation
///////////////////////////////////////////////////////////////////////////////
// Reserve reserviert den nächsten freien virtuellen Port.
//
// Es wird immer mit dem kleinsten freien Port im konfigurierten Bereich
// begonnen.
//
// Diese Methode bleibt für Fälle erhalten, in denen keine bestimmte
// Portnummer vorgegeben ist.
func (m *VirtualPortManager) Reserve() (int, error) {
if m == nil {
return 0, fmt.Errorf("virtual port manager is nil")
}
m.mu.Lock()
defer m.mu.Unlock()
for number := m.first; number <= m.last; number++ {
if m.used[number] {
continue
}
m.used[number] = true
return number, nil
}
return 0, fmt.Errorf(
"no free virtual port in range %d-%d",
m.first,
m.last,
)
}
// ReserveSpecific reserviert exakt den angegebenen virtuellen Port.
//
// Der Port muss innerhalb des konfigurierten Bereichs liegen und darf
// momentan nicht bereits reserviert sein.
//
// Diese Methode wird für konfigurierte Zuordnungen verwendet, bei denen
// die Portnummer aus client.json verbindlich ist.
func (m *VirtualPortManager) ReserveSpecific(number int) error {
if m == nil {
return fmt.Errorf("virtual port manager is nil")
}
m.mu.Lock()
defer m.mu.Unlock()
if number < m.first || number > m.last {
return fmt.Errorf(
"virtual port %d is outside configured range %d-%d",
number,
m.first,
m.last,
)
}
if m.used[number] {
return fmt.Errorf(
"virtual port %d is already reserved",
number,
)
}
m.used[number] = true
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Release
///////////////////////////////////////////////////////////////////////////////
// Release gibt einen zuvor reservierten Port wieder frei.
func (m *VirtualPortManager) Release(number int) {
if m == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
delete(m.used, number)
}

View file

@ -0,0 +1,80 @@
//go:build linux
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_manager_linux.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Linux-spezifische Erzeugung eines virtuellen rs2322tcp-Ports.
*
* Die gemeinsame Verwaltung von ManagedVirtualPort und VirtualPortManager
* befindet sich in virtual_port_manager_open.go.
*
* Unter Linux wird der PTY über newVirtualSerial() erzeugt und der interne
* Link unter ~/.rs2322tcp/virtual/ auf diesen PTY gesetzt.
* ============================================================================
*/
package client
import (
"fmt"
)
// openReserved creates the actual Linux virtual serial port for an already
// reserved port number.
//
// The caller must reserve the port before calling this method.
//
// The public /dev/ttyUSBxxx link is not modified here. The installation-owned
// link remains untouched. Only the user-owned internal link below
// ~/.rs2322tcp/virtual/ is created or updated to point to the newly created
// PTY.
func openReserved(
m *VirtualPortManager,
number int,
) (*ManagedVirtualPort, error) {
if m == nil {
return nil, fmt.Errorf("virtual port manager is nil")
}
portPath := m.PortPath(number)
serial, err := newVirtualSerial()
if err != nil {
m.Release(number)
return nil, fmt.Errorf(
"create virtual serial for %s: %w",
portPath,
err,
)
}
if err := setVirtualPortLink(
portPath,
serial.Path(),
); err != nil {
_ = serial.Close()
m.Release(number)
return nil, fmt.Errorf(
"bind %s to %s: %w",
portPath,
serial.Path(),
err,
)
}
port := NewVirtualPort(portPath, serial)
return &ManagedVirtualPort{
port: port,
manager: m,
number: number,
}, nil
}

View file

@ -0,0 +1,296 @@
//go:build linux
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_manager_linux_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Linux-spezifische Tests für die Erzeugung und Verwaltung virtueller
* rs2322tcp-Ports.
* ============================================================================
*/
package client
import (
"os"
"testing"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
///////////////////////////////////////////////////////////////////////////////
// Tests
///////////////////////////////////////////////////////////////////////////////
func TestVirtualPortManagerOpen(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 199,
},
)
port, err := manager.Open()
if err != nil {
t.Fatalf("Open() failed: %v", err)
}
if port == nil {
t.Fatal("Open() returned nil")
}
if port.Path() != "/dev/ttyUSB100" {
t.Errorf(
"Path() = %q, want %q",
port.Path(),
"/dev/ttyUSB100",
)
}
linkPath, err := virtualPortLinkPath(port.Path())
if err != nil {
t.Fatalf("virtualPortLinkPath() failed: %v", err)
}
target, err := os.Readlink(linkPath)
if err != nil {
t.Fatalf("Readlink() failed: %v", err)
}
if target == "" {
t.Fatal("internal virtual port link has empty target")
}
if err := port.Close(); err != nil {
t.Fatalf("Close() failed: %v", err)
}
targetAfterClose, err := os.Readlink(linkPath)
if err != nil {
t.Fatalf(
"internal link does not exist after Close(): %q: %v",
linkPath,
err,
)
}
if targetAfterClose != target {
t.Errorf(
"internal link target after Close() = %q, want %q",
targetAfterClose,
target,
)
}
}
func TestVirtualPortManagerOpenSpecific(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 199,
},
)
port, err := manager.OpenSpecific(150)
if err != nil {
t.Fatalf("OpenSpecific(150) failed: %v", err)
}
if port == nil {
t.Fatal("OpenSpecific(150) returned nil")
}
if port.Path() != "/dev/ttyUSB150" {
t.Errorf(
"Path() = %q, want %q",
port.Path(),
"/dev/ttyUSB150",
)
}
linkPath, err := virtualPortLinkPath(port.Path())
if err != nil {
t.Fatalf("virtualPortLinkPath() failed: %v", err)
}
target, err := os.Readlink(linkPath)
if err != nil {
t.Fatalf("Readlink() failed: %v", err)
}
if target == "" {
t.Fatal("internal virtual port link has empty target")
}
if err := port.Close(); err != nil {
t.Fatalf("Close() failed: %v", err)
}
targetAfterClose, err := os.Readlink(linkPath)
if err != nil {
t.Fatalf(
"internal link does not exist after Close(): %q: %v",
linkPath,
err,
)
}
if targetAfterClose != target {
t.Errorf(
"internal link target after Close() = %q, want %q",
targetAfterClose,
target,
)
}
}
func TestVirtualPortManagerOpenUsesNextPort(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 101,
},
)
first, err := manager.Open()
if err != nil {
t.Fatalf("first Open() failed: %v", err)
}
second, err := manager.Open()
if err != nil {
_ = first.Close()
t.Fatalf("second Open() failed: %v", err)
}
if first.Path() != "/dev/ttyUSB100" {
t.Errorf(
"first.Path() = %q, want %q",
first.Path(),
"/dev/ttyUSB100",
)
}
if second.Path() != "/dev/ttyUSB101" {
t.Errorf(
"second.Path() = %q, want %q",
second.Path(),
"/dev/ttyUSB101",
)
}
if err := first.Close(); err != nil {
t.Fatalf("first Close() failed: %v", err)
}
if err := second.Close(); err != nil {
t.Fatalf("second Close() failed: %v", err)
}
}
func TestVirtualPortManagerOpenReusesReleasedPort(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 100,
},
)
first, err := manager.Open()
if err != nil {
t.Fatalf("first Open() failed: %v", err)
}
if first.Path() != "/dev/ttyUSB100" {
t.Fatalf(
"first.Path() = %q, want %q",
first.Path(),
"/dev/ttyUSB100",
)
}
if err := first.Close(); err != nil {
t.Fatalf("first Close() failed: %v", err)
}
second, err := manager.Open()
if err != nil {
t.Fatalf("second Open() failed: %v", err)
}
if second.Path() != "/dev/ttyUSB100" {
t.Errorf(
"second.Path() = %q, want %q",
second.Path(),
"/dev/ttyUSB100",
)
}
if err := second.Close(); err != nil {
t.Fatalf("second Close() failed: %v", err)
}
}
func TestVirtualPortManagerOpenNoFreePort(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 100,
},
)
port, err := manager.Open()
if err != nil {
t.Fatalf("first Open() failed: %v", err)
}
defer port.Close()
if _, err := manager.Open(); err == nil {
t.Fatal("second Open() succeeded, want no-free-port error")
}
}
func TestManagedVirtualPortDataPath(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
manager := NewVirtualPortManager(
config.VirtualPortRangeConfig{
First: 100,
Last: 100,
},
)
port, err := manager.Open()
if err != nil {
t.Fatalf("Open() failed: %v", err)
}
defer port.Close()
data := []byte{0x46, 0x41, 0x00, 0x10, 0x0D}
if _, err := port.Write(data); err != nil {
t.Fatalf("Write() failed: %v", err)
}
}

View file

@ -0,0 +1,163 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_manager_open.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Plattformneutrale Verwaltung geöffneter virtueller Ports.
*
* Die konkrete Erzeugung eines virtuellen seriellen Ports erfolgt über
* openVirtualPort() in der jeweiligen Plattformimplementierung.
* ============================================================================
*/
package client
import (
"fmt"
"sync"
)
///////////////////////////////////////////////////////////////////////////////
// Managed virtual port
///////////////////////////////////////////////////////////////////////////////
// ManagedVirtualPort verbindet einen reservierten Port mit einer
// VirtualSerial-Instanz und verwaltet deren Lebenszyklus.
type ManagedVirtualPort struct {
mu sync.Mutex
port *VirtualPort
manager *VirtualPortManager
number int
}
// Path returns the device path visible to the external application.
func (p *ManagedVirtualPort) Path() string {
if p == nil {
return ""
}
p.mu.Lock()
defer p.mu.Unlock()
if p.port == nil {
return ""
}
return p.port.Path()
}
// Read reads data from the underlying virtual serial device.
func (p *ManagedVirtualPort) Read(b []byte) (int, error) {
if p == nil {
return 0, fmt.Errorf("managed virtual port is nil")
}
p.mu.Lock()
port := p.port
p.mu.Unlock()
if port == nil {
return 0, fmt.Errorf("managed virtual port is closed")
}
return port.Read(b)
}
// Write writes data to the underlying virtual serial device.
func (p *ManagedVirtualPort) Write(b []byte) (int, error) {
if p == nil {
return 0, fmt.Errorf("managed virtual port is nil")
}
p.mu.Lock()
port := p.port
p.mu.Unlock()
if port == nil {
return 0, fmt.Errorf("managed virtual port is closed")
}
return port.Write(b)
}
// Close closes the managed virtual port and releases its reservation.
func (p *ManagedVirtualPort) Close() error {
if p == nil {
return nil
}
p.mu.Lock()
port := p.port
p.port = nil
manager := p.manager
number := p.number
p.mu.Unlock()
if port == nil {
return nil
}
err := port.Close()
if manager != nil {
manager.Release(number)
}
return err
}
///////////////////////////////////////////////////////////////////////////////
// Open
///////////////////////////////////////////////////////////////////////////////
// Open creates a new virtual serial port using the next free port number.
func (m *VirtualPortManager) Open() (*ManagedVirtualPort, error) {
if m == nil {
return nil, fmt.Errorf("virtual port manager is nil")
}
number, err := m.Reserve()
if err != nil {
return nil, err
}
port, err := openVirtualPort(m, number)
if err != nil {
m.Release(number)
return nil, err
}
return port, nil
}
// OpenSpecific creates a new virtual serial port using exactly the specified
// port number.
func (m *VirtualPortManager) OpenSpecific(
number int,
) (*ManagedVirtualPort, error) {
if m == nil {
return nil, fmt.Errorf("virtual port manager is nil")
}
if err := m.ReserveSpecific(number); err != nil {
return nil, err
}
port, err := openVirtualPort(m, number)
if err != nil {
m.Release(number)
return nil, err
}
return port, nil
}

View file

@ -0,0 +1,304 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_manager_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für die Auswahl und Verwaltung virtueller serieller Ports.
*
* Die Tests decken sowohl die automatische Reservierung über Reserve()
* als auch die gezielte Reservierung eines bestimmten Ports über
* ReserveSpecific() ab.
* ============================================================================
*/
package client
import (
"testing"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
///////////////////////////////////////////////////////////////////////////////
// Tests
///////////////////////////////////////////////////////////////////////////////
func TestNewVirtualPortManager(t *testing.T) {
cfg := config.VirtualPortRangeConfig{
First: 100,
Last: 199,
}
manager := NewVirtualPortManager(cfg)
if manager == nil {
t.Fatal("NewVirtualPortManager() returned nil")
}
if manager.first != 100 {
t.Errorf("first = %d, want 100", manager.first)
}
if manager.last != 199 {
t.Errorf("last = %d, want 199", manager.last)
}
}
func TestVirtualPortManagerReserveFirst(t *testing.T) {
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
First: 100,
Last: 199,
})
number, err := manager.Reserve()
if err != nil {
t.Fatalf("Reserve() failed: %v", err)
}
if number != 100 {
t.Errorf("Reserve() = %d, want 100", number)
}
}
func TestVirtualPortManagerReserveNextFree(t *testing.T) {
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
First: 100,
Last: 199,
})
first, err := manager.Reserve()
if err != nil {
t.Fatalf("first Reserve() failed: %v", err)
}
second, err := manager.Reserve()
if err != nil {
t.Fatalf("second Reserve() failed: %v", err)
}
if first != 100 {
t.Errorf("first Reserve() = %d, want 100", first)
}
if second != 101 {
t.Errorf("second Reserve() = %d, want 101", second)
}
}
func TestVirtualPortManagerRelease(t *testing.T) {
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
First: 100,
Last: 199,
})
number, err := manager.Reserve()
if err != nil {
t.Fatalf("Reserve() failed: %v", err)
}
manager.Release(number)
reused, err := manager.Reserve()
if err != nil {
t.Fatalf("Reserve() after Release() failed: %v", err)
}
if reused != 100 {
t.Errorf(
"Reserve() after Release() = %d, want 100",
reused,
)
}
}
func TestVirtualPortManagerSkipsReservedPorts(t *testing.T) {
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
First: 100,
Last: 199,
})
for i := 0; i < 3; i++ {
if _, err := manager.Reserve(); err != nil {
t.Fatalf("Reserve() %d failed: %v", i+1, err)
}
}
number, err := manager.Reserve()
if err != nil {
t.Fatalf("fourth Reserve() failed: %v", err)
}
if number != 103 {
t.Errorf("fourth Reserve() = %d, want 103", number)
}
}
func TestVirtualPortManagerCustomRange(t *testing.T) {
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
First: 200,
Last: 299,
})
number, err := manager.Reserve()
if err != nil {
t.Fatalf("Reserve() failed: %v", err)
}
if number != 200 {
t.Errorf("Reserve() = %d, want 200", number)
}
}
func TestVirtualPortManagerNoFreePort(t *testing.T) {
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
First: 100,
Last: 102,
})
for i := 0; i < 3; i++ {
if _, err := manager.Reserve(); err != nil {
t.Fatalf("Reserve() %d failed: %v", i+1, err)
}
}
if _, err := manager.Reserve(); err == nil {
t.Fatal("Reserve() succeeded, want no-free-port error")
}
}
func TestVirtualPortManagerPortPath(t *testing.T) {
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
First: 100,
Last: 199,
})
tests := []struct {
number int
want string
}{
{
number: 100,
want: virtualPortPath(100),
},
{
number: 123,
want: virtualPortPath(123),
},
{
number: 199,
want: virtualPortPath(199),
},
}
for _, test := range tests {
got := manager.PortPath(test.number)
if got != test.want {
t.Errorf(
"PortPath(%d) = %q, want %q",
test.number,
got,
test.want,
)
}
}
}
func TestVirtualPortManagerReserveSpecific(t *testing.T) {
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
First: 100,
Last: 199,
})
if err := manager.ReserveSpecific(150); err != nil {
t.Fatalf("ReserveSpecific(150) failed: %v", err)
}
if !manager.used[150] {
t.Fatal("ReserveSpecific(150) did not reserve port 150")
}
}
func TestVirtualPortManagerReserveSpecificOutsideRange(t *testing.T) {
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
First: 100,
Last: 199,
})
tests := []int{
99,
200,
}
for _, number := range tests {
if err := manager.ReserveSpecific(number); err == nil {
t.Errorf(
"ReserveSpecific(%d) succeeded, want range error",
number,
)
}
if manager.used[number] {
t.Errorf(
"ReserveSpecific(%d) marked out-of-range port as used",
number,
)
}
}
}
func TestVirtualPortManagerReserveSpecificAlreadyReserved(t *testing.T) {
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
First: 100,
Last: 199,
})
if err := manager.ReserveSpecific(100); err != nil {
t.Fatalf("first ReserveSpecific(100) failed: %v", err)
}
if err := manager.ReserveSpecific(100); err == nil {
t.Fatal("second ReserveSpecific(100) succeeded, want already-reserved error")
}
}
func TestVirtualPortManagerReserveSpecificAfterRelease(t *testing.T) {
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
First: 100,
Last: 199,
})
if err := manager.ReserveSpecific(150); err != nil {
t.Fatalf("first ReserveSpecific(150) failed: %v", err)
}
manager.Release(150)
if err := manager.ReserveSpecific(150); err != nil {
t.Fatalf(
"ReserveSpecific(150) after Release() failed: %v",
err,
)
}
}
func TestVirtualPortManagerNil(t *testing.T) {
var manager *VirtualPortManager
if path := manager.PortPath(100); path != "" {
t.Errorf("nil PortPath() = %q, want empty string", path)
}
if _, err := manager.Reserve(); err == nil {
t.Fatal("nil Reserve() succeeded, want error")
}
if err := manager.ReserveSpecific(100); err == nil {
t.Fatal("nil ReserveSpecific() succeeded, want error")
}
manager.Release(100)
}

View file

@ -0,0 +1,59 @@
//go:build linux
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_number_linux.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Linux-spezifische Umwandlung eines konfigurierten virtuellen
* seriellen Ports in dessen numerische Portnummer.
* ============================================================================
*/
package client
import (
"fmt"
"strconv"
"strings"
)
///////////////////////////////////////////////////////////////////////////////
// Virtual port number
///////////////////////////////////////////////////////////////////////////////
// virtualPortNumber converts a configured /dev/ttyUSBxxx path into its
// numeric virtual port number.
func virtualPortNumber(path string) (int, error) {
const prefix = "/dev/ttyUSB"
if !strings.HasPrefix(path, prefix) {
return 0, fmt.Errorf(
"invalid virtual port %q: expected /dev/ttyUSB<number>",
path,
)
}
value := strings.TrimPrefix(path, prefix)
if value == "" {
return 0, fmt.Errorf(
"invalid virtual port %q: port number is missing",
path,
)
}
number, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf(
"invalid virtual port %q: invalid port number",
path,
)
}
return number, nil
}

View file

@ -0,0 +1,62 @@
//go:build windows
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_number_windows.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Windows-spezifische Umwandlung eines konfigurierten virtuellen
* seriellen COM-Ports in dessen numerische Portnummer.
* ============================================================================
*/
package client
import (
"fmt"
"strconv"
"strings"
)
///////////////////////////////////////////////////////////////////////////////
// Virtual port number
///////////////////////////////////////////////////////////////////////////////
// virtualPortNumber converts a configured COMxxx path into its numeric
// virtual port number.
func virtualPortNumber(path string) (int, error) {
const prefix = "COM"
if !strings.HasPrefix(
strings.ToUpper(path),
prefix,
) {
return 0, fmt.Errorf(
"invalid virtual port %q: expected COM<number>",
path,
)
}
value := path[len(prefix):]
if value == "" {
return 0, fmt.Errorf(
"invalid virtual port %q: port number is missing",
path,
)
}
number, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf(
"invalid virtual port %q: invalid port number",
path,
)
}
return number, nil
}

View file

@ -0,0 +1,77 @@
//go:build linux
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_open_linux.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Linux-spezifische Erzeugung eines verwalteten virtuellen rs2322tcp-Ports.
*
* Die virtuelle serielle Schnittstelle wird über ein PTY-Paar erzeugt.
* Der öffentliche /dev/ttyUSBxxx-Eintrag wird über den bereits vorhandenen
* internen Link auf den PTY-Slave abgebildet.
* ============================================================================
*/
package client
import "fmt"
///////////////////////////////////////////////////////////////////////////////
// Open virtual port
///////////////////////////////////////////////////////////////////////////////
// openVirtualPort creates and initializes a Linux virtual serial port.
//
// The port number must already have been reserved by the
// VirtualPortManager.
//
// On failure, the caller remains responsible for releasing the reservation.
func openVirtualPort(
manager *VirtualPortManager,
number int,
) (*ManagedVirtualPort, error) {
if manager == nil {
return nil, fmt.Errorf("virtual port manager is nil")
}
portPath := manager.PortPath(number)
serial, err := newVirtualSerial()
if err != nil {
return nil, fmt.Errorf(
"create virtual serial for %s: %w",
portPath,
err,
)
}
if err := setVirtualPortLink(
portPath,
serial.Path(),
); err != nil {
_ = serial.Close()
return nil, fmt.Errorf(
"bind %s to %s: %w",
portPath,
serial.Path(),
err,
)
}
port := NewVirtualPort(
portPath,
serial,
)
return &ManagedVirtualPort{
port: port,
manager: manager,
number: number,
}, nil
}

View file

@ -0,0 +1,87 @@
//go:build windows
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_open_windows.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Windows-spezifische Erzeugung eines verwalteten virtuellen rs2322tcp-Ports.
*
* Der konfigurierte virtuelle COM-Port wird einmal geöffnet und anschließend
* über dasselbe Handle für Lese- und Schreiboperationen verwendet.
* ============================================================================
*/
package client
import (
"fmt"
bugserial "go.bug.st/serial"
)
///////////////////////////////////////////////////////////////////////////////
// Open virtual port
///////////////////////////////////////////////////////////////////////////////
// openVirtualPort creates and initializes a Windows virtual serial port.
//
// The port number must already have been reserved by the
// VirtualPortManager.
//
// The visible COM port is provided by the platform-specific PortPath()
// implementation. The actual COM-port pair configuration is managed
// externally by the Windows virtual COM-port driver.
//
// The COM port is opened exactly once. The resulting handle is used for
// parallel read and write operations.
func openVirtualPort(
manager *VirtualPortManager,
number int,
) (*ManagedVirtualPort, error) {
if manager == nil {
return nil, fmt.Errorf("virtual port manager is nil")
}
portPath := manager.PortPath(number)
if portPath == "" {
return nil, fmt.Errorf(
"virtual port path is empty for port %d",
number,
)
}
mode := &bugserial.Mode{
BaudRate: 9600,
DataBits: 8,
Parity: bugserial.NoParity,
StopBits: bugserial.OneStopBit,
}
port, err := bugserial.Open(portPath, mode)
if err != nil {
return nil, fmt.Errorf(
"open virtual serial port %s: %w",
portPath,
err,
)
}
virtualPort := NewVirtualPort(
portPath,
&windowsSerial{
port: port,
path: portPath,
},
)
return &ManagedVirtualPort{
port: virtualPort,
manager: manager,
number: number,
}, nil
}

View file

@ -0,0 +1,43 @@
//go:build linux
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_path_linux.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Linux-spezifische Darstellung eines für rs2322tcp reservierten
* virtuellen seriellen Ports.
*
* Unter Linux werden die für rs2322tcp sichtbaren virtuellen Ports als
*
* /dev/ttyUSBxxx
*
* dargestellt.
*
* Die eigentliche Bereitstellung des virtuellen seriellen Ports erfolgt
* unabhängig davon in der Linux-spezifischen VirtualSerial-Implementierung.
* ============================================================================
*/
package client
import "fmt"
///////////////////////////////////////////////////////////////////////////////
// Virtual port path
///////////////////////////////////////////////////////////////////////////////
// virtualPortPath erzeugt den unter Linux sichtbaren Gerätenamen für eine
// virtuelle rs2322tcp-Schnittstelle.
//
// Beispiel:
//
// number = 100
// result = /dev/ttyUSB100
func virtualPortPath(number int) string {
return fmt.Sprintf("/dev/ttyUSB%d", number)
}

View file

@ -0,0 +1,48 @@
//go:build windows
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_path_windows.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Windows-spezifische Darstellung eines für rs2322tcp reservierten
* virtuellen seriellen Ports.
*
* Unter Windows werden die für rs2322tcp sichtbaren virtuellen Ports als
*
* COMxxx
*
* dargestellt.
*
* Die eigentliche Bereitstellung des virtuellen COM-Ports erfolgt durch die
* Windows-spezifische serielle Infrastruktur beziehungsweise später durch
* den für rs2322tcp vorgesehenen virtuellen COM-Port-Treiber.
*
* Diese Datei enthält ausschließlich die plattformabhängige Darstellung des
* Portnamens. Die gemeinsame Portverwaltung bleibt in
* virtual_port_manager.go erhalten.
* ============================================================================
*/
package client
import "fmt"
///////////////////////////////////////////////////////////////////////////////
// Virtual port path
///////////////////////////////////////////////////////////////////////////////
// virtualPortPath erzeugt den unter Windows sichtbaren Gerätenamen für eine
// virtuelle rs2322tcp-Schnittstelle.
//
// Beispiel:
//
// number = 10
// result = COM10
func virtualPortPath(number int) string {
return fmt.Sprintf("COM%d", number)
}

View file

@ -0,0 +1,117 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für die lokale virtuelle seriellen Schnittstelle.
* ============================================================================
*/
package client
import "testing"
///////////////////////////////////////////////////////////////////////////////
// Tests
///////////////////////////////////////////////////////////////////////////////
func TestNewVirtualPort(t *testing.T) {
serial := newTestVirtualSerial(nil)
port := NewVirtualPort("/dev/ttyUSB100", serial)
if port == nil {
t.Fatal("NewVirtualPort() returned nil")
}
if port.Path() != "/dev/ttyUSB100" {
t.Errorf("Path() = %q, want %q",
port.Path(), "/dev/ttyUSB100")
}
}
func TestVirtualPortRead(t *testing.T) {
expected := []byte{0x46, 0x41, 0x00, 0x10, 0x0D}
serial := newTestVirtualSerial(expected)
port := NewVirtualPort("/dev/ttyUSB100", serial)
buffer := make([]byte, len(expected))
n, err := port.Read(buffer)
if err != nil {
t.Fatalf("Read() failed: %v", err)
}
if n != len(expected) {
t.Fatalf("Read() returned %d bytes, want %d",
n, len(expected))
}
for i := range expected {
if buffer[i] != expected[i] {
t.Errorf("buffer[%d] = %02X, want %02X",
i, buffer[i], expected[i])
}
}
}
func TestVirtualPortWrite(t *testing.T) {
serial := newTestVirtualSerial(nil)
port := NewVirtualPort("/dev/ttyUSB100", serial)
data := []byte{0x46, 0x41, 0x00, 0x10, 0x0D}
n, err := port.Write(data)
if err != nil {
t.Fatalf("Write() failed: %v", err)
}
if n != len(data) {
t.Fatalf("Write() returned %d, want %d",
n, len(data))
}
written := serial.Written()
if len(written) != len(data) {
t.Fatalf("underlying serial received %d bytes, want %d",
len(written), len(data))
}
for i := range data {
if written[i] != data[i] {
t.Errorf("written[%d] = %02X, want %02X",
i, written[i], data[i])
}
}
}
func TestVirtualPortClose(t *testing.T) {
serial := newTestVirtualSerial(nil)
port := NewVirtualPort("/dev/ttyUSB100", serial)
if err := port.Close(); err != nil {
t.Fatalf("Close() failed: %v", err)
}
// Ein zweiter Close darf keinen Fehler erzeugen.
if err := port.Close(); err != nil {
t.Fatalf("second Close() failed: %v", err)
}
}
func TestVirtualPortNil(t *testing.T) {
var port *VirtualPort
if port.Path() != "" {
t.Errorf("nil Path() = %q, want empty string", port.Path())
}
if err := port.Close(); err != nil {
t.Errorf("nil Close() failed: %v", err)
}
}

View file

@ -0,0 +1,252 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_validation.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Plattformneutrale Prüfung der in client.json definierten virtuellen Ports
* gegen die tatsächlich vorhandenen virtuellen Schnittstellen.
*
* Die Ermittlung der vorhandenen Ports erfolgt über die
* plattformabhängige Funktion existingVirtualPortLinks().
* ============================================================================
*/
package client
import (
"fmt"
"sort"
"strings"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
///////////////////////////////////////////////////////////////////////////////
// Virtual port validation
///////////////////////////////////////////////////////////////////////////////
// VirtualPortValidationResult contains the differences between configured
// and locally available virtual ports.
type VirtualPortValidationResult struct {
Missing []string
Extra []string
}
// compareVirtualPorts compares the configured virtual ports with the
// locally available virtual ports.
//
// Missing contains ports configured in client.json that are not available
// locally.
//
// Extra contains locally available ports that are not configured in
// client.json.
//
// The order of both input lists is irrelevant.
func compareVirtualPorts(
configured []string,
available []string,
) VirtualPortValidationResult {
configuredSet := make(map[string]struct{}, len(configured))
for _, port := range configured {
configuredSet[port] = struct{}{}
}
availableSet := make(map[string]struct{}, len(available))
for _, port := range available {
availableSet[port] = struct{}{}
}
result := VirtualPortValidationResult{
Missing: make([]string, 0),
Extra: make([]string, 0),
}
for port := range configuredSet {
if _, ok := availableSet[port]; ok {
continue
}
result.Missing = append(result.Missing, port)
}
for port := range availableSet {
if _, ok := configuredSet[port]; ok {
continue
}
result.Extra = append(result.Extra, port)
}
sort.Strings(result.Missing)
sort.Strings(result.Extra)
return result
}
///////////////////////////////////////////////////////////////////////////////
// Local virtual-port validation
///////////////////////////////////////////////////////////////////////////////
// validateLocalVirtualPorts compares the virtual ports defined in
// client.json with the virtual ports currently available on the local
// computer.
//
// The detection of available ports is performed by the platform-specific
// existingVirtualPortLinks() implementation.
//
// A mismatch prevents the client from starting.
func validateLocalVirtualPorts(
cfg *config.ClientConfig,
) error {
if cfg == nil {
return fmt.Errorf("client configuration is nil")
}
configured := make(
[]string,
0,
len(cfg.VirtualPorts),
)
for _, virtualPort := range cfg.VirtualPorts {
configured = append(
configured,
virtualPort.Port,
)
}
available, err := existingVirtualPortLinks(
cfg.VirtualPortRange,
)
if err != nil {
return fmt.Errorf(
"prüfen der virtuellen Ports fehlgeschlagen: %w",
err,
)
}
result := compareVirtualPorts(
configured,
available,
)
if len(result.Missing) == 0 &&
len(result.Extra) == 0 {
return nil
}
var message strings.Builder
////////////////////////////////////////////////////////////////////////////
// Missing ports
////////////////////////////////////////////////////////////////////////////
if len(result.Missing) > 0 &&
len(result.Extra) == 0 {
message.WriteString(
"Virtuelle Ports nicht eingerichtet",
)
message.WriteString(
"\n\nFolgende Ports sind in client.json " +
"eingetragen, aber auf diesem Rechner " +
"nicht eingerichtet:\n\n",
)
for _, port := range result.Missing {
message.WriteString(" ")
message.WriteString(port)
message.WriteByte('\n')
}
message.WriteString(
"\nBitte richten Sie diese virtuellen Ports " +
"entsprechend der Installationsanleitung ein.",
)
return fmt.Errorf(
"%s",
strings.TrimSpace(message.String()),
)
}
////////////////////////////////////////////////////////////////////////////
// Extra ports
////////////////////////////////////////////////////////////////////////////
if len(result.Missing) == 0 &&
len(result.Extra) > 0 {
message.WriteString(
"Virtuelle Ports nicht konfiguriert",
)
message.WriteString(
"\n\nFolgende virtuelle Ports sind auf diesem " +
"Rechner eingerichtet, aber nicht in " +
"client.json eingetragen:\n\n",
)
for _, port := range result.Extra {
message.WriteString(" ")
message.WriteString(port)
message.WriteByte('\n')
}
message.WriteString(
"\nBitte ergänzen Sie die entsprechenden " +
"Einträge in client.json.",
)
return fmt.Errorf(
"%s",
strings.TrimSpace(message.String()),
)
}
////////////////////////////////////////////////////////////////////////////
// Missing and extra ports
////////////////////////////////////////////////////////////////////////////
message.WriteString(
"Virtuelle Portkonfiguration stimmt nicht überein",
)
message.WriteString(
"\n\nIn client.json eingetragen, aber nicht " +
"eingerichtet:\n\n",
)
for _, port := range result.Missing {
message.WriteString(" ")
message.WriteString(port)
message.WriteByte('\n')
}
message.WriteString(
"\nEingerichtet, aber nicht in client.json " +
"eingetragen:\n\n",
)
for _, port := range result.Extra {
message.WriteString(" ")
message.WriteString(port)
message.WriteByte('\n')
}
message.WriteString(
"\nBitte korrigieren Sie die virtuelle " +
"Portkonfiguration.",
)
return fmt.Errorf(
"%s",
strings.TrimSpace(message.String()),
)
}

View file

@ -0,0 +1,106 @@
//go:build linux
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_validation_linux.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Linux-spezifische Ermittlung der lokal vorhandenen virtuellen
* rs2322tcp-Schnittstellen.
*
* Die öffentliche /dev/ttyUSBxxx-Schnittstelle wird ausschließlich gelesen.
* Der Client verändert diese Schnittstellen hier nicht.
* ============================================================================
*/
package client
import (
"fmt"
"os"
"path/filepath"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
///////////////////////////////////////////////////////////////////////////////
// Existing virtual ports
///////////////////////////////////////////////////////////////////////////////
// existingVirtualPortLinks returns all public virtual-port paths that
// currently exist as symbolic links.
//
// Only ports inside the configured virtual-port range are considered.
// The public /dev/ttyUSBxxx links are only inspected; they are never
// created, changed, or removed by this function.
//
// This check only requires read access and therefore does not require
// root privileges.
func existingVirtualPortLinks(
cfg config.VirtualPortRangeConfig,
) ([]string, error) {
return existingVirtualPortLinksInDirectory(
cfg,
"/dev",
)
}
///////////////////////////////////////////////////////////////////////////////
// Existing virtual ports in directory
///////////////////////////////////////////////////////////////////////////////
// existingVirtualPortLinksInDirectory returns all virtual-port paths in
// directory that currently exist as symbolic links.
//
// This helper is deliberately separated from existingVirtualPortLinks so
// the filesystem inspection can be tested without modifying /dev.
func existingVirtualPortLinksInDirectory(
cfg config.VirtualPortRangeConfig,
directory string,
) ([]string, error) {
if directory == "" {
return nil, fmt.Errorf("virtual port directory is empty")
}
manager := NewVirtualPortManager(cfg)
if manager == nil {
return nil, fmt.Errorf("virtual port manager is nil")
}
links := make([]string, 0)
for number := cfg.First; number <= cfg.Last; number++ {
portName := filepath.Base(
manager.PortPath(number),
)
portPath := filepath.Join(directory, portName)
info, err := os.Lstat(portPath)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, fmt.Errorf(
"inspect virtual port %q: %w",
portPath,
err,
)
}
if info.Mode()&os.ModeSymlink == 0 {
continue
}
links = append(
links,
filepath.Join(directory, portName),
)
}
return links, nil
}

View file

@ -0,0 +1,283 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_validation_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für den Vergleich der konfigurierten und tatsächlich vorhandenen
* virtuellen rs2322tcp-Schnittstellen.
* ============================================================================
*/
package client
import (
"reflect"
"testing"
)
func TestCompareVirtualPortsIdentical(t *testing.T) {
configured := []string{
"/dev/ttyUSB100",
"/dev/ttyUSB101",
}
available := []string{
"/dev/ttyUSB101",
"/dev/ttyUSB100",
}
got := compareVirtualPorts(
configured,
available,
)
if len(got.Missing) != 0 {
t.Errorf(
"Missing = %v, want empty",
got.Missing,
)
}
if len(got.Extra) != 0 {
t.Errorf(
"Extra = %v, want empty",
got.Extra,
)
}
}
func TestCompareVirtualPortsMissing(t *testing.T) {
configured := []string{
"/dev/ttyUSB100",
"/dev/ttyUSB101",
"/dev/ttyUSB102",
}
available := []string{
"/dev/ttyUSB100",
"/dev/ttyUSB101",
}
got := compareVirtualPorts(
configured,
available,
)
wantMissing := []string{
"/dev/ttyUSB102",
}
if !reflect.DeepEqual(got.Missing, wantMissing) {
t.Errorf(
"Missing = %v, want %v",
got.Missing,
wantMissing,
)
}
if len(got.Extra) != 0 {
t.Errorf(
"Extra = %v, want empty",
got.Extra,
)
}
}
func TestCompareVirtualPortsExtra(t *testing.T) {
configured := []string{
"/dev/ttyUSB100",
"/dev/ttyUSB101",
}
available := []string{
"/dev/ttyUSB100",
"/dev/ttyUSB101",
"/dev/ttyUSB103",
}
got := compareVirtualPorts(
configured,
available,
)
if len(got.Missing) != 0 {
t.Errorf(
"Missing = %v, want empty",
got.Missing,
)
}
wantExtra := []string{
"/dev/ttyUSB103",
}
if !reflect.DeepEqual(got.Extra, wantExtra) {
t.Errorf(
"Extra = %v, want %v",
got.Extra,
wantExtra,
)
}
}
func TestCompareVirtualPortsMissingAndExtra(t *testing.T) {
configured := []string{
"/dev/ttyUSB100",
"/dev/ttyUSB101",
"/dev/ttyUSB102",
}
available := []string{
"/dev/ttyUSB100",
"/dev/ttyUSB101",
"/dev/ttyUSB103",
}
got := compareVirtualPorts(
configured,
available,
)
wantMissing := []string{
"/dev/ttyUSB102",
}
wantExtra := []string{
"/dev/ttyUSB103",
}
if !reflect.DeepEqual(got.Missing, wantMissing) {
t.Errorf(
"Missing = %v, want %v",
got.Missing,
wantMissing,
)
}
if !reflect.DeepEqual(got.Extra, wantExtra) {
t.Errorf(
"Extra = %v, want %v",
got.Extra,
wantExtra,
)
}
}
func TestCompareVirtualPortsEmpty(t *testing.T) {
got := compareVirtualPorts(
nil,
nil,
)
if len(got.Missing) != 0 {
t.Errorf(
"Missing = %v, want empty",
got.Missing,
)
}
if len(got.Extra) != 0 {
t.Errorf(
"Extra = %v, want empty",
got.Extra,
)
}
}
func TestCompareVirtualPortsConfiguredOnly(t *testing.T) {
configured := []string{
"/dev/ttyUSB100",
"/dev/ttyUSB101",
}
got := compareVirtualPorts(
configured,
nil,
)
wantMissing := []string{
"/dev/ttyUSB100",
"/dev/ttyUSB101",
}
if !reflect.DeepEqual(got.Missing, wantMissing) {
t.Errorf(
"Missing = %v, want %v",
got.Missing,
wantMissing,
)
}
if len(got.Extra) != 0 {
t.Errorf(
"Extra = %v, want empty",
got.Extra,
)
}
}
func TestCompareVirtualPortsAvailableOnly(t *testing.T) {
available := []string{
"/dev/ttyUSB100",
"/dev/ttyUSB101",
}
got := compareVirtualPorts(
nil,
available,
)
if len(got.Missing) != 0 {
t.Errorf(
"Missing = %v, want empty",
got.Missing,
)
}
wantExtra := []string{
"/dev/ttyUSB100",
"/dev/ttyUSB101",
}
if !reflect.DeepEqual(got.Extra, wantExtra) {
t.Errorf(
"Extra = %v, want %v",
got.Extra,
wantExtra,
)
}
}
func TestCompareVirtualPortsIgnoresDuplicates(t *testing.T) {
configured := []string{
"/dev/ttyUSB100",
"/dev/ttyUSB100",
}
available := []string{
"/dev/ttyUSB100",
"/dev/ttyUSB100",
}
got := compareVirtualPorts(
configured,
available,
)
if len(got.Missing) != 0 {
t.Errorf(
"Missing = %v, want empty",
got.Missing,
)
}
if len(got.Extra) != 0 {
t.Errorf(
"Extra = %v, want empty",
got.Extra,
)
}
}

View file

@ -0,0 +1,132 @@
//go:build windows
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_port_validation_windows.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Windows-spezifische Ermittlung der lokal vorhandenen seriellen
* Schnittstellen für rs2322tcp.
*
* Unter Windows werden virtuelle Ports als COMxxx-Schnittstellen
* bereitgestellt.
*
* Diese Implementierung prüft den konfigurierten Portbereich und liefert
* die tatsächlich vorhandenen COM-Schnittstellen zurück.
*
* Die Funktion verändert keine Geräte und installiert keine Treiber.
* ============================================================================
*/
package client
import (
"fmt"
"golang.org/x/sys/windows/registry"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
///////////////////////////////////////////////////////////////////////////////
// Existing virtual ports
///////////////////////////////////////////////////////////////////////////////
// existingVirtualPortLinks returns all COM ports in the configured range
// that are currently registered as serial ports in Windows.
//
// The Windows serial-port information is obtained from the Plug-and-Play
// device registry. No device is created, modified, or removed.
func existingVirtualPortLinks(
cfg config.VirtualPortRangeConfig,
) ([]string, error) {
key, err := registry.OpenKey(
registry.LOCAL_MACHINE,
`HARDWARE\DEVICEMAP\SERIALCOMM`,
registry.READ,
)
if err != nil {
return nil, fmt.Errorf(
"open Windows serial-port registry: %w",
err,
)
}
defer key.Close()
values, err := key.ReadValueNames(0)
if err != nil {
return nil, fmt.Errorf(
"read Windows serial-port registry: %w",
err,
)
}
available := make([]string, 0)
for _, valueName := range values {
portName, _, err := key.GetStringValue(valueName)
if err != nil {
continue
}
number, ok := windowsCOMPortNumber(portName)
if !ok {
continue
}
if number < cfg.First || number > cfg.Last {
continue
}
available = append(
available,
portName,
)
}
return available, nil
}
///////////////////////////////////////////////////////////////////////////////
// COM port number
///////////////////////////////////////////////////////////////////////////////
// windowsCOMPortNumber extracts the numeric part of a Windows COM port name.
//
// Examples:
//
// COM1 -> 1
// COM10 -> 10
//
// The comparison is case-insensitive.
func windowsCOMPortNumber(portName string) (int, bool) {
if len(portName) < 4 {
return 0, false
}
if (portName[0] != 'C' && portName[0] != 'c') ||
(portName[1] != 'O' && portName[1] != 'o') ||
(portName[2] != 'M' && portName[2] != 'm') {
return 0, false
}
number := 0
for _, char := range portName[3:] {
if char < '0' || char > '9' {
return 0, false
}
number = number*10 + int(char-'0')
}
if number == 0 {
return 0, false
}
return number, true
}

View file

@ -5,8 +5,8 @@ import "io"
// VirtualSerial repräsentiert eine vom Client bereitgestellte
// virtuelle serielle Schnittstelle.
//
// Path liefert den Namen der für die Anwendung sichtbaren
// seriellen Schnittstelle, zum Beispiel /dev/pts/1.
// Path liefert den Pfad des internen virtuellen seriellen Geräts,
// beispielsweise /dev/pts/2.
//
// Das io.ReadWriteCloser-Interface repräsentiert die interne
// Byte-Verbindung zum virtuellen seriellen Gerät.

View file

@ -0,0 +1,210 @@
//go:build windows
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/virtual_serial_windows.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Windows-spezifische Implementierung der virtuellen seriellen Schnittstelle
* über go.bug.st/serial.
*
* Ein COM-Port wird unter Windows nur einmal geöffnet. Dasselbe Handle wird
* parallel für Lese- und Schreiboperationen verwendet. Damit kann eine
* Goroutine blockierend lesen, während eine andere Goroutine Daten schreibt.
* ============================================================================
*/
package client
import (
"fmt"
"sync"
bugserial "go.bug.st/serial"
)
///////////////////////////////////////////////////////////////////////////////
// windowsSerial
///////////////////////////////////////////////////////////////////////////////
// windowsSerial implements VirtualSerial using one Windows COM-port handle
// for both reading and writing.
type windowsSerial struct {
mu sync.Mutex
port bugserial.Port
path string
}
///////////////////////////////////////////////////////////////////////////////
// Path
///////////////////////////////////////////////////////////////////////////////
// Path returns the Windows COM-port name.
func (p *windowsSerial) Path() string {
if p == nil {
return ""
}
return p.path
}
///////////////////////////////////////////////////////////////////////////////
// Read
///////////////////////////////////////////////////////////////////////////////
// Read reads raw bytes from the Windows COM-port handle.
func (p *windowsSerial) Read(b []byte) (int, error) {
if p == nil {
return 0, fmt.Errorf("virtual serial port is nil")
}
p.mu.Lock()
port := p.port
path := p.path
p.mu.Unlock()
if port == nil {
return 0, fmt.Errorf("virtual serial port is closed")
}
n, err := port.Read(b)
if n > 0 {
fmt.Printf(
"WINDOWS SERIAL RX [%s]: %d Bytes\n% X\n",
path,
n,
b[:n],
)
}
if err != nil {
fmt.Printf(
"WINDOWS SERIAL RX FEHLER [%s]: nach %d Bytes: %v\n",
path,
n,
err,
)
}
return n, err
}
///////////////////////////////////////////////////////////////////////////////
// Write
///////////////////////////////////////////////////////////////////////////////
// Write writes raw bytes to the Windows COM-port handle.
func (p *windowsSerial) Write(b []byte) (int, error) {
if p == nil {
return 0, fmt.Errorf("virtual serial port is nil")
}
p.mu.Lock()
port := p.port
path := p.path
p.mu.Unlock()
if port == nil {
return 0, fmt.Errorf("virtual serial port is closed")
}
fmt.Printf(
"WINDOWS SERIAL TX [%s]: %d Bytes\n% X\n",
path,
len(b),
b,
)
fmt.Printf(
"WINDOWS SERIAL TX [%s]: Rufe port.Write() auf ...\n",
path,
)
n, err := port.Write(b)
fmt.Printf(
"WINDOWS SERIAL TX [%s]: port.Write() zurückgekehrt: %d von %d Bytes\n",
path,
n,
len(b),
)
if err != nil {
fmt.Printf(
"WINDOWS SERIAL TX FEHLER [%s]: %v\n",
path,
err,
)
return n, err
}
if n != len(b) {
fmt.Printf(
"WINDOWS SERIAL TX UNVOLLSTÄNDIG [%s]: %d von %d Bytes\n",
path,
n,
len(b),
)
} else {
fmt.Printf(
"WINDOWS SERIAL TX ERFOLGREICH [%s]: %d Bytes\n",
path,
n,
)
}
return n, nil
}
///////////////////////////////////////////////////////////////////////////////
// Close
///////////////////////////////////////////////////////////////////////////////
// Close closes the Windows COM-port handle.
func (p *windowsSerial) Close() error {
if p == nil {
return nil
}
p.mu.Lock()
port := p.port
path := p.path
p.port = nil
p.mu.Unlock()
if port == nil {
return nil
}
fmt.Printf(
"WINDOWS SERIAL [%s]: Schließe COM-Port ...\n",
path,
)
if err := port.Close(); err != nil {
fmt.Printf(
"WINDOWS SERIAL [%s]: Fehler beim Schließen des COM-Ports: %v\n",
path,
err,
)
return err
}
fmt.Printf(
"WINDOWS SERIAL [%s]: COM-Port geschlossen.\n",
path,
)
return nil
}

View file

@ -2,8 +2,8 @@
Package config provides configuration types and JSON handling for rs2322tcp.
The server configuration describes the physical serial devices available
on the remote system. The client configuration describes the connection
to the server and the user's assignment of virtual serial ports to remote
on the remote system. The client configuration describes the connection to
the server and the user's assignment of virtual serial ports to remote
devices.
Project: rs2322tcp
@ -23,8 +23,10 @@ import (
// ServerConfig contains the complete server configuration.
type ServerConfig struct {
Listen ListenConfig `json:"listen"`
Devices []DeviceConfig `json:"devices"`
Listen ListenConfig `json:"listen"`
HardwareErrorResponse string `json:"hardware_error_response"`
SerialMonitor bool `json:"serial_monitor"`
Devices []DeviceConfig `json:"devices"`
}
// ListenConfig contains the network listener configuration.
@ -50,8 +52,9 @@ type DeviceConfig struct {
// ClientConfig contains the complete client configuration.
type ClientConfig struct {
Server ServerConnectionConfig `json:"server"`
VirtualPorts []VirtualPortConfig `json:"virtual_ports"`
Server ServerConnectionConfig `json:"server"`
VirtualPortRange VirtualPortRangeConfig `json:"virtual_port_range"`
VirtualPorts []VirtualPortConfig `json:"virtual_ports"`
}
// ServerConnectionConfig contains the connection information for the
@ -61,11 +64,47 @@ type ServerConnectionConfig struct {
Port int `json:"port"`
}
// VirtualPortRangeConfig defines the reserved range of /dev/ttyUSB device
// numbers that may be used for virtual rs2322tcp ports.
type VirtualPortRangeConfig struct {
First int `json:"first"`
Last int `json:"last"`
}
// VirtualPortConfig describes one local virtual serial port and the
// remote device assigned to it.
//
// RemoteDevice may be empty. An empty RemoteDevice means that the virtual
// port exists but is currently not assigned to a remote device.
type VirtualPortConfig struct {
Port string `json:"port"`
RemoteDevice string `json:"remote_device"`
StartupBytes string `json:"startup_bytes,omitempty"`
}
///////////////////////////////////////////////////////////////////////////////
// Defaults
///////////////////////////////////////////////////////////////////////////////
const (
// DefaultVirtualPortFirst is the first virtual USB serial port number
// used when no virtual port range is specified.
DefaultVirtualPortFirst = 100
// DefaultVirtualPortLast is the last virtual USB serial port number
// used when no virtual port range is specified.
DefaultVirtualPortLast = 199
// Fehlermeldung wenn Hardware nicht erreichbar ist
DefaultHardwareErrorResponse = "ERROR - HARDWARE NOT AVAILABLE"
)
// DefaultVirtualPortRange returns the default virtual port range.
func DefaultVirtualPortRange() VirtualPortRangeConfig {
return VirtualPortRangeConfig{
First: DefaultVirtualPortFirst,
Last: DefaultVirtualPortLast,
}
}
///////////////////////////////////////////////////////////////////////////////
@ -80,6 +119,10 @@ func LoadServer(filename string) (*ServerConfig, error) {
return nil, err
}
if cfg.HardwareErrorResponse == "" {
cfg.HardwareErrorResponse = DefaultHardwareErrorResponse
}
if err := cfg.Validate(); err != nil {
return nil, err
}
@ -95,6 +138,12 @@ func LoadClient(filename string) (*ClientConfig, error) {
return nil, err
}
// A missing virtual_port_range is intentionally supported for backwards
// compatibility. Apply the documented default before validation.
if cfg.VirtualPortRange.First == 0 && cfg.VirtualPortRange.Last == 0 {
cfg.VirtualPortRange = DefaultVirtualPortRange()
}
if err := cfg.Validate(); err != nil {
return nil, err
}
@ -125,6 +174,10 @@ func SaveClient(filename string, cfg *ClientConfig) error {
return fmt.Errorf("client configuration is nil")
}
if cfg.VirtualPortRange.First == 0 && cfg.VirtualPortRange.Last == 0 {
cfg.VirtualPortRange = DefaultVirtualPortRange()
}
if err := cfg.Validate(); err != nil {
return err
}
@ -180,6 +233,9 @@ func (cfg *ServerConfig) Validate() error {
}
// Validate checks the client configuration for basic errors.
//
// A virtual port may exist without a remote-device assignment. This is a
// valid state and means that the virtual port is currently free.
func (cfg *ClientConfig) Validate() error {
if cfg == nil {
return fmt.Errorf("client configuration is nil")
@ -189,21 +245,37 @@ func (cfg *ClientConfig) Validate() error {
return fmt.Errorf("invalid server port: %d", cfg.Server.Port)
}
if cfg.VirtualPortRange.First < DefaultVirtualPortFirst {
return fmt.Errorf(
"virtual port range first must be >= %d: %d",
DefaultVirtualPortFirst,
cfg.VirtualPortRange.First,
)
}
if cfg.VirtualPortRange.Last < cfg.VirtualPortRange.First {
return fmt.Errorf(
"invalid virtual port range: %d-%d",
cfg.VirtualPortRange.First,
cfg.VirtualPortRange.Last,
)
}
ports := make(map[string]bool)
for i, virtualPort := range cfg.VirtualPorts {
if virtualPort.Port == "" {
return fmt.Errorf("virtual port %d: port is empty", i)
}
if virtualPort.RemoteDevice == "" {
return fmt.Errorf("virtual port %q: remote device is empty",
virtualPort.Port)
return fmt.Errorf(
"virtual port %d: port is empty",
i,
)
}
if ports[virtualPort.Port] {
return fmt.Errorf("duplicate virtual port: %q",
virtualPort.Port)
return fmt.Errorf(
"duplicate virtual port: %q",
virtualPort.Port,
)
}
ports[virtualPort.Port] = true
@ -219,11 +291,19 @@ func (cfg *ClientConfig) Validate() error {
func loadJSON(filename string, target interface{}) error {
data, err := os.ReadFile(filename)
if err != nil {
return fmt.Errorf("read configuration %q: %w", filename, err)
return fmt.Errorf(
"read configuration %q: %w",
filename,
err,
)
}
if err := json.Unmarshal(data, target); err != nil {
return fmt.Errorf("parse configuration %q: %w", filename, err)
return fmt.Errorf(
"parse configuration %q: %w",
filename,
err,
)
}
return nil
@ -238,7 +318,11 @@ func saveJSON(filename string, value interface{}) error {
data = append(data, '\n')
if err := os.WriteFile(filename, data, 0644); err != nil {
return fmt.Errorf("write configuration %q: %w", filename, err)
return fmt.Errorf(
"write configuration %q: %w",
filename,
err,
)
}
return nil

View file

@ -8,11 +8,13 @@ package config_test
import (
"encoding/json"
"git.lang-dieter.de/rs2322tcp/internal/config"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
///////////////////////////////////////////////////////////////////////////////
@ -50,14 +52,24 @@ func TestLoadServer(t *testing.T) {
t.Fatalf("LoadServer() failed: %v", err)
}
if cfg.HardwareErrorResponse != config.DefaultHardwareErrorResponse {
t.Errorf(
"HardwareErrorResponse = %q, want %q",
cfg.HardwareErrorResponse,
config.DefaultHardwareErrorResponse,
)
}
if cfg.Listen.Address != "0.0.0.0" {
t.Errorf("Listen.Address = %q, want %q",
cfg.Listen.Address, "0.0.0.0")
t.Errorf(
"Listen.Address = %q, want %q",
cfg.Listen.Address,
"0.0.0.0",
)
}
if cfg.Listen.Port != 5000 {
t.Errorf("Listen.Port = %d, want %d",
cfg.Listen.Port, 5000)
t.Errorf("Listen.Port = %d, want %d", cfg.Listen.Port, 5000)
}
if len(cfg.Devices) != 1 {
@ -67,18 +79,19 @@ func TestLoadServer(t *testing.T) {
device := cfg.Devices[0]
if device.ID != "radio" {
t.Errorf("Device.ID = %q, want %q",
device.ID, "radio")
t.Errorf("Device.ID = %q, want %q", device.ID, "radio")
}
if device.SerialPort != "/dev/ttyUSB0" {
t.Errorf("Device.SerialPort = %q, want %q",
device.SerialPort, "/dev/ttyUSB0")
t.Errorf(
"Device.SerialPort = %q, want %q",
device.SerialPort,
"/dev/ttyUSB0",
)
}
if device.BaudRate != 9600 {
t.Errorf("Device.BaudRate = %d, want %d",
device.BaudRate, 9600)
t.Errorf("Device.BaudRate = %d, want %d", device.BaudRate, 9600)
}
}
@ -113,30 +126,146 @@ func TestLoadClient(t *testing.T) {
}
if cfg.Server.Address != "100.64.0.10" {
t.Errorf("Server.Address = %q, want %q",
cfg.Server.Address, "100.64.0.10")
t.Errorf(
"Server.Address = %q, want %q",
cfg.Server.Address,
"100.64.0.10",
)
}
if cfg.Server.Port != 5000 {
t.Errorf("Server.Port = %d, want %d",
cfg.Server.Port, 5000)
t.Errorf("Server.Port = %d, want %d", cfg.Server.Port, 5000)
}
if cfg.VirtualPortRange.First != config.DefaultVirtualPortFirst {
t.Errorf(
"VirtualPortRange.First = %d, want %d",
cfg.VirtualPortRange.First,
config.DefaultVirtualPortFirst,
)
}
if cfg.VirtualPortRange.Last != config.DefaultVirtualPortLast {
t.Errorf(
"VirtualPortRange.Last = %d, want %d",
cfg.VirtualPortRange.Last,
config.DefaultVirtualPortLast,
)
}
if len(cfg.VirtualPorts) != 1 {
t.Fatalf("len(VirtualPorts) = %d, want 1",
len(cfg.VirtualPorts))
t.Fatalf(
"len(VirtualPorts) = %d, want 1",
len(cfg.VirtualPorts),
)
}
virtualPort := cfg.VirtualPorts[0]
if virtualPort.Port != "COM7" {
t.Errorf("VirtualPort.Port = %q, want %q",
virtualPort.Port, "COM7")
t.Errorf(
"VirtualPort.Port = %q, want %q",
virtualPort.Port,
"COM7",
)
}
if virtualPort.RemoteDevice != "radio" {
t.Errorf("VirtualPort.RemoteDevice = %q, want %q",
virtualPort.RemoteDevice, "radio")
t.Errorf(
"VirtualPort.RemoteDevice = %q, want %q",
virtualPort.RemoteDevice,
"radio",
)
}
if virtualPort.StartupBytes != "" {
t.Errorf(
"VirtualPort.StartupBytes = %q, want empty",
virtualPort.StartupBytes,
)
}
}
func TestLoadClientWithStartupBytes(t *testing.T) {
dir := t.TempDir()
filename := filepath.Join(dir, "client.json")
data := `{
"server": {
"address": "100.64.0.10",
"port": 5000
},
"virtual_ports": [
{
"port": "COM7",
"remote_device": "radio",
"startup_bytes": "57 00 00 1F 20"
}
]
}`
if err := os.WriteFile(filename, []byte(data), 0644); err != nil {
t.Fatalf("write test configuration: %v", err)
}
cfg, err := config.LoadClient(filename)
if err != nil {
t.Fatalf("LoadClient() failed: %v", err)
}
if len(cfg.VirtualPorts) != 1 {
t.Fatalf(
"len(VirtualPorts) = %d, want 1",
len(cfg.VirtualPorts),
)
}
if cfg.VirtualPorts[0].StartupBytes != "57 00 00 1F 20" {
t.Errorf(
"VirtualPort.StartupBytes = %q, want %q",
cfg.VirtualPorts[0].StartupBytes,
"57 00 00 1F 20",
)
}
}
func TestLoadClientCustomVirtualPortRange(t *testing.T) {
dir := t.TempDir()
filename := filepath.Join(dir, "client.json")
data := `{
"server": {
"address": "100.64.0.10",
"port": 5000
},
"virtual_port_range": {
"first": 200,
"last": 299
},
"virtual_ports": []
}`
if err := os.WriteFile(filename, []byte(data), 0644); err != nil {
t.Fatalf("write test configuration: %v", err)
}
cfg, err := config.LoadClient(filename)
if err != nil {
t.Fatalf("LoadClient() failed: %v", err)
}
if cfg.VirtualPortRange.First != 200 {
t.Errorf(
"VirtualPortRange.First = %d, want 200",
cfg.VirtualPortRange.First,
)
}
if cfg.VirtualPortRange.Last != 299 {
t.Errorf(
"VirtualPortRange.Last = %d, want 299",
cfg.VirtualPortRange.Last,
)
}
}
@ -153,10 +282,15 @@ func TestSaveAndLoadClient(t *testing.T) {
Address: "100.64.0.10",
Port: 5000,
},
VirtualPortRange: config.VirtualPortRangeConfig{
First: 200,
Last: 299,
},
VirtualPorts: []config.VirtualPortConfig{
{
Port: "COM7",
RemoteDevice: "radio",
StartupBytes: "57 00 00 1F 20",
},
{
Port: "COM8",
@ -178,10 +312,16 @@ func TestSaveAndLoadClient(t *testing.T) {
t.Errorf("loaded Server differs from original")
}
if loaded.VirtualPortRange != original.VirtualPortRange {
t.Errorf("loaded VirtualPortRange differs from original")
}
if len(loaded.VirtualPorts) != len(original.VirtualPorts) {
t.Fatalf("len(VirtualPorts) = %d, want %d",
t.Fatalf(
"len(VirtualPorts) = %d, want %d",
len(loaded.VirtualPorts),
len(original.VirtualPorts))
len(original.VirtualPorts),
)
}
for i := range original.VirtualPorts {
@ -228,6 +368,7 @@ func TestClientValidationDuplicateVirtualPort(t *testing.T) {
Server: config.ServerConnectionConfig{
Port: 5000,
},
VirtualPortRange: config.DefaultVirtualPortRange(),
VirtualPorts: []config.VirtualPortConfig{
{
Port: "COM7",
@ -262,6 +403,7 @@ func TestClientValidationInvalidPort(t *testing.T) {
Server: config.ServerConnectionConfig{
Port: 0,
},
VirtualPortRange: config.DefaultVirtualPortRange(),
}
if err := cfg.Validate(); err == nil {
@ -269,6 +411,54 @@ func TestClientValidationInvalidPort(t *testing.T) {
}
}
func TestClientValidationVirtualPortRangeBelowReservedArea(t *testing.T) {
cfg := &config.ClientConfig{
Server: config.ServerConnectionConfig{
Port: 5000,
},
VirtualPortRange: config.VirtualPortRangeConfig{
First: 99,
Last: 199,
},
}
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() succeeded, want range-below-reserved-area error")
}
}
func TestClientValidationVirtualPortRangeReversed(t *testing.T) {
cfg := &config.ClientConfig{
Server: config.ServerConnectionConfig{
Port: 5000,
},
VirtualPortRange: config.VirtualPortRangeConfig{
First: 200,
Last: 100,
},
}
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() succeeded, want reversed-range error")
}
}
func TestClientValidationVirtualPortRangeValid(t *testing.T) {
cfg := &config.ClientConfig{
Server: config.ServerConnectionConfig{
Port: 5000,
},
VirtualPortRange: config.VirtualPortRangeConfig{
First: 200,
Last: 299,
},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() failed: %v", err)
}
}
///////////////////////////////////////////////////////////////////////////////
// Example configurations
///////////////////////////////////////////////////////////////////////////////
@ -279,8 +469,8 @@ func TestExampleServerConfig(t *testing.T) {
t.Fatalf("LoadServer() failed: %v", err)
}
if len(cfg.Devices) != 2 {
t.Fatalf("len(Devices) = %d, want 2", len(cfg.Devices))
if len(cfg.Devices) != 3 {
t.Fatalf("len(Devices) = %d, want 3", len(cfg.Devices))
}
}
@ -290,8 +480,35 @@ func TestExampleClientConfig(t *testing.T) {
t.Fatalf("LoadClient() failed: %v", err)
}
if len(cfg.VirtualPorts) != 2 {
t.Fatalf("len(VirtualPorts) = %d, want 2", len(cfg.VirtualPorts))
if len(cfg.VirtualPorts) != 1 {
t.Fatalf(
"len(VirtualPorts) = %d, want 1",
len(cfg.VirtualPorts),
)
}
expectedPort := "COM100"
if runtime.GOOS == "linux" {
expectedPort = "/dev/ttyUSB100"
}
port := cfg.VirtualPorts[0]
if port.Port != expectedPort {
t.Errorf(
"VirtualPorts[0].Port = %q, want %q",
port.Port,
expectedPort,
)
}
if port.RemoteDevice != "rotor" {
t.Errorf(
"VirtualPorts[0].RemoteDevice = %q, want %q",
port.RemoteDevice,
"rotor",
)
}
}
@ -313,33 +530,51 @@ func TestDeviceRemoteDevice(t *testing.T) {
remote := device.RemoteDevice()
if remote.ID != "radio" {
t.Errorf("RemoteDevice.ID = %q, want %q",
remote.ID, "radio")
t.Errorf(
"RemoteDevice.ID = %q, want %q",
remote.ID,
"radio",
)
}
if remote.Name != "Funkgerät" {
t.Errorf("RemoteDevice.Name = %q, want %q",
remote.Name, "Funkgerät")
t.Errorf(
"RemoteDevice.Name = %q, want %q",
remote.Name,
"Funkgerät",
)
}
if remote.BaudRate != 9600 {
t.Errorf("RemoteDevice.BaudRate = %d, want %d",
remote.BaudRate, 9600)
t.Errorf(
"RemoteDevice.BaudRate = %d, want %d",
remote.BaudRate,
9600,
)
}
if remote.DataBits != 8 {
t.Errorf("RemoteDevice.DataBits = %d, want %d",
remote.DataBits, 8)
t.Errorf(
"RemoteDevice.DataBits = %d, want %d",
remote.DataBits,
8,
)
}
if remote.Parity != "none" {
t.Errorf("RemoteDevice.Parity = %q, want %q",
remote.Parity, "none")
t.Errorf(
"RemoteDevice.Parity = %q, want %q",
remote.Parity,
"none",
)
}
if remote.StopBits != 1 {
t.Errorf("RemoteDevice.StopBits = %d, want %d",
remote.StopBits, 1)
t.Errorf(
"RemoteDevice.StopBits = %d, want %d",
remote.StopBits,
1,
)
}
}
@ -378,28 +613,41 @@ func TestServerRemoteDevices(t *testing.T) {
}
if list.Devices[0].ID != "radio" {
t.Errorf("Devices[0].ID = %q, want %q",
list.Devices[0].ID, "radio")
t.Errorf(
"Devices[0].ID = %q, want %q",
list.Devices[0].ID,
"radio",
)
}
if list.Devices[1].ID != "rotor" {
t.Errorf("Devices[1].ID = %q, want %q",
list.Devices[1].ID, "rotor")
t.Errorf(
"Devices[1].ID = %q, want %q",
list.Devices[1].ID,
"rotor",
)
}
if list.Devices[0].Name != "Funkgerät" {
t.Errorf("Devices[0].Name = %q, want %q",
list.Devices[0].Name, "Funkgerät")
t.Errorf(
"Devices[0].Name = %q, want %q",
list.Devices[0].Name,
"Funkgerät",
)
}
if list.Devices[0].BaudRate != 9600 {
t.Errorf("Devices[0].BaudRate = %d, want %d",
list.Devices[0].BaudRate, 9600)
t.Errorf(
"Devices[0].BaudRate = %d, want 9600",
list.Devices[0].BaudRate,
)
}
if list.Devices[1].BaudRate != 4800 {
t.Errorf("Devices[1].BaudRate = %d, want %d",
list.Devices[1].BaudRate, 4800)
t.Errorf(
"Devices[1].BaudRate = %d, want 4800",
list.Devices[1].BaudRate,
)
}
}
@ -449,11 +697,17 @@ func TestRemoteDeviceJSON(t *testing.T) {
}
if len(decoded.Devices) != 1 {
t.Fatalf("len(Devices) = %d, want 1", len(decoded.Devices))
t.Fatalf(
"len(decoded.Devices) = %d, want 1",
len(decoded.Devices),
)
}
if decoded.Devices[0].ID != "radio" {
t.Errorf("Devices[0].ID = %q, want %q",
decoded.Devices[0].ID, "radio")
t.Errorf(
"Devices[0].ID = %q, want %q",
decoded.Devices[0].ID,
"radio",
)
}
}

729
internal/gui/app.go Normal file
View file

@ -0,0 +1,729 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/gui/app.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Grafische Benutzeroberfläche des rs2322tcp-Clients.
*
* Die eigentliche Kommunikation mit dem Server sowie die Verwaltung der
* virtuellen seriellen Ports bleiben vollständig in internal/client.
*
* Die Gerätezuordnung wird in einem separaten Fenster bearbeitet.
*
* Änderungen an der Gerätezuordnung werden zunächst ausschließlich im
* Arbeitsspeicher des Zuordnungsfensters gehalten.
*
* Beim Schließen des Zuordnungsfensters werden vorhandene Änderungen
* gespeichert.
*
* Wird dagegen das Hauptfenster geschlossen, während das Zuordnungsfenster
* noch ungespeicherte Änderungen enthält, wird der Anwender gefragt, ob
* diese Änderungen verworfen werden sollen.
* ============================================================================
*/
package gui
import (
"fmt"
"sort"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"git.lang-dieter.de/rs2322tcp/internal/client"
)
///////////////////////////////////////////////////////////////////////////////
// App
///////////////////////////////////////////////////////////////////////////////
// App represents the graphical rs2322tcp client application.
//
// App owns the Fyne window and the technical client application. The GUI
// does not access the Runtime or the Client directly. All technical client
// operations are performed through client.Application.
type App struct {
fyneApp fyne.App
window fyne.Window
clientApplication *client.Application
statusLabel *widget.Label
errorLabel *widget.Label
reconnectButton *widget.Button
assignmentButton *widget.Button
assignmentWindow fyne.Window
assignmentEditor *AssignmentEditor
}
///////////////////////////////////////////////////////////////////////////////
// Construction
///////////////////////////////////////////////////////////////////////////////
// NewApp creates the graphical rs2322tcp client application.
//
// No network connection is established by NewApp. Start must be called
// separately.
func NewApp(
fyneApp fyne.App,
configFile string,
) (*App, error) {
if fyneApp == nil {
return nil, fmt.Errorf("fyne application is nil")
}
application, err := client.NewApplication(configFile)
if err != nil {
return nil, err
}
window := fyneApp.NewWindow("rs2322tcp Client")
window.Resize(fyne.NewSize(800, 600))
app := &App{
fyneApp: fyneApp,
window: window,
clientApplication: application,
statusLabel: widget.NewLabel("Server: nicht verbunden"),
errorLabel: widget.NewLabel(""),
}
app.reconnectButton = widget.NewButton(
"Server neu verbinden",
app.reconnect,
)
app.assignmentButton = widget.NewButton(
"Gerätezuordnung",
app.showAssignments,
)
app.buildContent()
// Das Schließen des Hauptfensters wird abgefangen, damit ein noch
// geöffnetes Zuordnungsfenster mit ungespeicherten Änderungen nicht
// stillschweigend verworfen wird.
window.SetCloseIntercept(
app.handleMainWindowClose,
)
return app, nil
}
///////////////////////////////////////////////////////////////////////////////
// Main window
///////////////////////////////////////////////////////////////////////////////
// buildContent creates the main-window content.
func (a *App) buildContent() {
title := widget.NewLabel("rs2322tcp Client")
content := container.NewVBox(
title,
a.statusLabel,
a.errorLabel,
a.assignmentButton,
a.reconnectButton,
)
a.window.SetContent(content)
}
///////////////////////////////////////////////////////////////////////////////
// Client lifecycle
///////////////////////////////////////////////////////////////////////////////
// Start starts the technical client application in the background.
//
// The Fyne window remains responsive while the connection to the server is
// established.
func (a *App) Start() {
if a == nil {
return
}
a.statusLabel.SetText(
"Server: Verbindung wird aufgebaut ...",
)
a.errorLabel.SetText("")
a.reconnectButton.Disable()
go func() {
err := a.clientApplication.Start()
if err != nil {
fyne.Do(func() {
a.statusLabel.SetText(
"Server: nicht verbunden",
)
a.errorLabel.SetText("")
a.showStartupError(err)
})
return
}
devices := a.clientApplication.Devices()
fyne.Do(func() {
a.statusLabel.SetText(
fmt.Sprintf(
"Server: verbunden %d Geräte verfügbar",
len(devices),
),
)
a.errorLabel.SetText("")
a.reconnectButton.Enable()
})
}()
}
// reconnect starts a complete reconnect of the technical client.
func (a *App) reconnect() {
if a == nil {
return
}
a.statusLabel.SetText(
"Server: Verbindung wird neu aufgebaut ...",
)
a.errorLabel.SetText("")
a.reconnectButton.Disable()
go func() {
err := a.clientApplication.Reconnect()
if err != nil {
fyne.Do(func() {
a.statusLabel.SetText(
"Server: nicht verbunden",
)
a.errorLabel.SetText("")
a.showStartupError(err)
})
return
}
devices := a.clientApplication.Devices()
fyne.Do(func() {
a.statusLabel.SetText(
fmt.Sprintf(
"Server: verbunden %d Geräte verfügbar",
len(devices),
),
)
a.errorLabel.SetText("")
a.reconnectButton.Enable()
})
}()
}
///////////////////////////////////////////////////////////////////////////////
// Startup error
///////////////////////////////////////////////////////////////////////////////
// showStartupError displays a modal error dialog for a client startup error.
//
// The dialog intentionally has only an "OK" button. A client configuration
// error must not be bypassed because starting the client with inconsistent
// virtual-port configuration could result in communication being directed
// to the wrong or missing local interface.
//
// After the user acknowledges the error, the technical client is closed and
// the complete Fyne application is terminated.
func (a *App) showStartupError(err error) {
if a == nil || a.window == nil || err == nil {
return
}
title := widget.NewLabelWithStyle(
"Die virtuellen Ports konnten nicht geprüft werden.",
fyne.TextAlignLeading,
fyne.TextStyle{
Bold: true,
},
)
message := widget.NewLabel(
err.Error(),
)
message.Wrapping = fyne.TextWrapWord
content := container.NewVBox(
title,
widget.NewSeparator(),
message,
)
confirm := dialog.NewCustomWithoutButtons(
"Virtuelle Ports",
container.NewPadded(content),
a.window,
)
okButton := widget.NewButton(
"OK",
func() {
confirm.Dismiss()
_ = a.clientApplication.Close()
a.window.SetCloseIntercept(nil)
a.fyneApp.Quit()
},
)
confirm.SetButtons(
[]fyne.CanvasObject{
okButton,
},
)
confirm.Show()
confirm.Resize(
fyne.NewSize(650, 420),
)
}
///////////////////////////////////////////////////////////////////////////////
// Device assignment
///////////////////////////////////////////////////////////////////////////////
// showAssignments opens the editable device-assignment window.
func (a *App) showAssignments() {
if a == nil {
return
}
// Nur ein Zuordnungsfenster gleichzeitig.
if a.assignmentWindow != nil {
a.assignmentWindow.Show()
return
}
cfg := a.clientApplication.Config()
devices := a.clientApplication.Devices()
editor := NewAssignmentEditor(
cfg,
devices,
)
window := a.fyneApp.NewWindow(
"Gerätezuordnung",
)
window.Resize(
fyne.NewSize(650, 450),
)
a.assignmentWindow = window
a.assignmentEditor = editor
// Beim Schließen über das Fenstersymbol wird dieselbe Logik wie beim
// Button "Schließen" verwendet.
window.SetCloseIntercept(
func() {
a.handleAssignmentWindowClose(
window,
editor,
)
},
)
window.SetOnClosed(func() {
if a.assignmentWindow == window {
a.assignmentWindow = nil
a.assignmentEditor = nil
}
})
a.refreshAssignmentWindow(
window,
editor,
)
window.Show()
}
// refreshAssignmentWindow rebuilds all assignment Select widgets.
//
// This is intentional. When a device is disconnected, its former port
// becomes free and must immediately become selectable for the other
// currently unconnected devices.
func (a *App) refreshAssignmentWindow(
window fyne.Window,
editor *AssignmentEditor,
) {
if a == nil || window == nil || editor == nil {
return
}
deviceIDs := editorDeviceIDs(editor)
objects := make(
[]fyne.CanvasObject,
0,
len(deviceIDs)*2,
)
objects = append(
objects,
widget.NewLabelWithStyle(
"Server-Gerät",
fyne.TextAlignLeading,
fyne.TextStyle{Bold: true},
),
)
objects = append(
objects,
widget.NewLabelWithStyle(
"Lokale Schnittstelle",
fyne.TextAlignLeading,
fyne.TextStyle{Bold: true},
),
)
for _, deviceID := range deviceIDs {
deviceID := deviceID
deviceLabel := widget.NewLabel(deviceID)
selectBox := widget.NewSelect(
editor.Options(deviceID),
nil,
)
currentPort := editor.CurrentPort(deviceID)
if currentPort == "" {
selectBox.SetSelected(
NotConnected,
)
} else {
selectBox.SetSelected(
currentPort,
)
}
selectBox.OnChanged = func(selected string) {
if !editor.Set(
deviceID,
selected,
) {
return
}
// Nach jeder Änderung werden sämtliche Selectboxen neu
// aufgebaut. Dadurch werden gerade freigegebene Ports
// sofort bei allen anderen Geräten sichtbar.
a.refreshAssignmentWindow(
window,
editor,
)
}
objects = append(
objects,
deviceLabel,
selectBox,
)
}
grid := container.New(
layout.NewFormLayout(),
objects...,
)
info := widget.NewLabel(
"Die Änderungen werden beim Schließen gespeichert.",
)
closeButton := widget.NewButton(
"Schließen",
func() {
a.handleAssignmentWindowClose(
window,
editor,
)
},
)
footer := container.NewVBox(
info,
closeButton,
)
window.SetContent(
container.NewBorder(
nil,
footer,
nil,
nil,
container.NewVScroll(grid),
),
)
}
// editorDeviceIDs returns all server-device IDs known by the assignment
// editor in deterministic order.
func editorDeviceIDs(
editor *AssignmentEditor,
) []string {
if editor == nil {
return nil
}
deviceIDs := make(
[]string,
0,
len(editor.Current),
)
for deviceID := range editor.Current {
deviceIDs = append(
deviceIDs,
deviceID,
)
}
sort.Strings(deviceIDs)
return deviceIDs
}
///////////////////////////////////////////////////////////////////////////////
// Assignment saving
///////////////////////////////////////////////////////////////////////////////
// handleAssignmentWindowClose handles closing of the assignment window.
//
// The "Schließen" button saves changed assignments. If there are no changes,
// the window is simply closed.
func (a *App) handleAssignmentWindowClose(
window fyne.Window,
editor *AssignmentEditor,
) {
if a == nil || window == nil || editor == nil {
return
}
if !editor.Dirty() {
a.closeAssignmentWindowWithoutPrompt(
window,
)
return
}
a.saveAssignmentsAndClose(
window,
editor,
)
}
// saveAssignmentsAndClose converts the editor state into a client
// configuration, saves it through client.Application and closes the
// assignment window only after a successful save.
func (a *App) saveAssignmentsAndClose(
window fyne.Window,
editor *AssignmentEditor,
) {
if a == nil || window == nil || editor == nil {
return
}
cfg := a.clientApplication.Config()
if cfg == nil {
dialog.ShowError(
fmt.Errorf(
"Client-Konfiguration ist nicht verfügbar",
),
window,
)
return
}
updatedCfg := editor.ApplyToConfig(cfg)
if updatedCfg == nil {
dialog.ShowError(
fmt.Errorf(
"Gerätezuordnung konnte nicht übernommen werden",
),
window,
)
return
}
if err := a.clientApplication.SaveConfig(
updatedCfg,
); err != nil {
dialog.ShowError(
fmt.Errorf(
"Client-Konfiguration konnte nicht gespeichert werden: %w",
err,
),
window,
)
return
}
// Erst nach erfolgreichem Speichern schließen.
a.closeAssignmentWindowWithoutPrompt(
window,
)
}
// closeAssignmentWindowWithoutPrompt closes the assignment window without
// invoking its close intercept again.
func (a *App) closeAssignmentWindowWithoutPrompt(
window fyne.Window,
) {
if window == nil {
return
}
window.SetCloseIntercept(nil)
window.Close()
}
///////////////////////////////////////////////////////////////////////////////
// Main window closing
///////////////////////////////////////////////////////////////////////////////
// handleMainWindowClose handles closing of the main window.
//
// If the assignment window is open and contains unsaved changes, the user
// must explicitly decide whether those changes should be discarded.
func (a *App) handleMainWindowClose() {
if a == nil {
return
}
if a.assignmentEditor == nil ||
!a.assignmentEditor.Dirty() {
a.finishMainWindowClose()
return
}
confirm := dialog.NewConfirm(
"Ungespeicherte Änderungen",
"Die Gerätezuordnung enthält ungespeicherte "+
"Änderungen.\n\n"+
"Wenn das Hauptfenster geschlossen wird, "+
"gehen diese Änderungen verloren.\n\n"+
"Möchtest du die Änderungen verwerfen und "+
"den Client schließen?",
func(discard bool) {
if !discard {
return
}
a.finishMainWindowClose()
},
a.window,
)
confirm.SetConfirmText(
"Änderungen verwerfen",
)
confirm.SetDismissText(
"Abbrechen",
)
confirm.Show()
}
// finishMainWindowClose closes the assignment window first and then the
// main application window.
//
// The close intercepts are removed because the user has already confirmed
// the operation.
func (a *App) finishMainWindowClose() {
if a == nil {
return
}
if a.assignmentWindow != nil {
window := a.assignmentWindow
window.SetCloseIntercept(nil)
window.Close()
a.assignmentWindow = nil
a.assignmentEditor = nil
}
a.window.SetCloseIntercept(nil)
a.window.Close()
}
///////////////////////////////////////////////////////////////////////////////
// Public lifecycle
///////////////////////////////////////////////////////////////////////////////
// Close closes the technical client application.
//
// This method is also used by cmd/rs2322tcp-client after the Fyne main
// window has actually closed. Any remaining assignment window is closed
// without prompting because the main-window close handling has already
// taken place.
func (a *App) Close() {
if a == nil {
return
}
if a.assignmentWindow != nil {
window := a.assignmentWindow
window.SetCloseIntercept(nil)
window.Close()
a.assignmentWindow = nil
a.assignmentEditor = nil
}
_ = a.clientApplication.Close()
}
// ShowAndRun displays the main window and starts the Fyne event loop.
func (a *App) ShowAndRun() {
if a == nil {
return
}
a.window.ShowAndRun()
}
// Window returns the main application window.
func (a *App) Window() fyne.Window {
if a == nil {
return nil
}
return a.window
}

485
internal/gui/assignment.go Normal file
View file

@ -0,0 +1,485 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/gui/assignment.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* GUI-unabhängige Aufbereitung und Bearbeitung der Zuordnung zwischen den
* vom rs2322tcp-Server angebotenen Geräten und den lokalen virtuellen
* seriellen Schnittstellen.
*
* Die Datei enthält bewusst keine Fyne-Abhängigkeiten.
*
* Die Änderungen an der Gerätezuordnung werden zunächst ausschließlich im
* Arbeitsspeicher des Zuordnungsfensters gehalten. Das Speichern in die
* Client-Konfiguration erfolgt beim Schließen des Fensters.
*
* Regeln:
*
* - Ein Server-Gerät kann höchstens einer lokalen Schnittstelle zugeordnet
* werden.
*
* - Eine lokale Schnittstelle kann höchstens einem Server-Gerät zugeordnet
* werden.
*
* - "nicht verbunden" ist ein gültiger Zustand.
*
* - Ist ein Gerät bereits verbunden, kann es nur auf seinen aktuellen Port
* oder auf "nicht verbunden" gesetzt werden.
*
* - Ist ein Gerät nicht verbunden, kann es auf "nicht verbunden" oder auf
* einen momentan freien konfigurierten virtuellen Port gesetzt werden.
*
* - Wird ein Gerät getrennt, bleibt der virtuelle Port erhalten und wird
* lediglich frei.
* ============================================================================
*/
package gui
import (
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
///////////////////////////////////////////////////////////////////////////////
// Constants
///////////////////////////////////////////////////////////////////////////////
const (
// NotConnected is the GUI representation of an unassigned server device.
NotConnected = "nicht verbunden"
)
///////////////////////////////////////////////////////////////////////////////
// Device assignment
///////////////////////////////////////////////////////////////////////////////
// DeviceAssignment beschreibt eine bereits in der Client-Konfiguration
// vorhandene Zuordnung zwischen einer lokalen virtuellen Schnittstelle
// und einem entfernten Gerät.
//
// Available gibt an, ob das konfigurierte Remote-Gerät momentan in der
// vom Server gelieferten Geräteliste vorhanden ist.
type DeviceAssignment struct {
LocalPort string
RemoteDevice string
Available bool
}
///////////////////////////////////////////////////////////////////////////////
// Assignment editor
///////////////////////////////////////////////////////////////////////////////
// AssignmentEditor enthält den momentan im GUI bearbeiteten
// Zuordnungszustand.
//
// Original enthält den Zustand beim Öffnen des Fensters.
//
// Current enthält den momentan vom Anwender bearbeiteten Zustand.
//
// Der Schlüssel ist jeweils die stabile Remote-Device-ID. Ein leerer
// Port bedeutet "nicht verbunden".
//
// Ports enthält alle konfigurierten virtuellen Ports. Diese Ports bleiben
// auch dann erhalten, wenn sie momentan keinem Remote-Gerät zugeordnet sind.
type AssignmentEditor struct {
Original map[string]string
Current map[string]string
Ports []string
}
// NewAssignmentEditor erzeugt einen bearbeitbaren Zuordnungszustand aus
// der bestehenden Client-Konfiguration und der aktuellen Geräteliste.
//
// Es werden ausschließlich bereits konfigurierte virtuelle Ports betrachtet.
// Das Anlegen neuer virtueller Ports erfolgt in einem späteren
// Entwicklungsschritt.
func NewAssignmentEditor(
cfg *config.ClientConfig,
devices []transport.RemoteDeviceInfo,
) *AssignmentEditor {
editor := &AssignmentEditor{
Original: make(map[string]string),
Current: make(map[string]string),
Ports: make([]string, 0),
}
if cfg != nil {
for _, virtualPort := range cfg.VirtualPorts {
if virtualPort.Port == "" {
continue
}
if !containsString(
editor.Ports,
virtualPort.Port,
) {
editor.Ports = append(
editor.Ports,
virtualPort.Port,
)
}
if virtualPort.RemoteDevice == "" {
continue
}
editor.Current[virtualPort.RemoteDevice] =
virtualPort.Port
}
}
for _, device := range devices {
if _, exists := editor.Current[device.ID]; !exists {
editor.Current[device.ID] = ""
}
}
editor.Original = cloneAssignments(
editor.Current,
)
return editor
}
// CurrentPort returns the currently selected local port for a server device.
//
// An empty string means "nicht verbunden".
func (e *AssignmentEditor) CurrentPort(
deviceID string,
) string {
if e == nil {
return ""
}
return e.Current[deviceID]
}
// Options returns the currently valid Selectbox options for one server
// device.
//
// For an already connected device:
//
// - current port
// - nicht verbunden
//
// For an unconnected device:
//
// - nicht verbunden
// - all currently free configured virtual ports
func (e *AssignmentEditor) Options(
deviceID string,
) []string {
if e == nil {
return []string{
NotConnected,
}
}
currentPort := e.Current[deviceID]
if currentPort != "" {
return []string{
currentPort,
NotConnected,
}
}
options := []string{
NotConnected,
}
for _, port := range e.Ports {
if e.portUsedByOtherDevice(
port,
deviceID,
) {
continue
}
options = append(
options,
port,
)
}
return options
}
// Set changes the current assignment of one server device.
//
// An empty port or NotConnected disconnects the device.
//
// A non-empty port is accepted only when it is a configured virtual port
// and is not currently used by another server device.
//
// The function returns true when the current state was changed.
func (e *AssignmentEditor) Set(
deviceID string,
port string,
) bool {
if e == nil {
return false
}
if port == NotConnected {
port = ""
}
currentPort := e.Current[deviceID]
if currentPort == port {
return false
}
if port != "" {
if !containsString(
e.Ports,
port,
) {
return false
}
if e.portUsedByOtherDevice(
port,
deviceID,
) {
return false
}
}
e.Current[deviceID] = port
return true
}
// Dirty reports whether the current assignment state differs from the
// state that was present when the editor was opened.
func (e *AssignmentEditor) Dirty() bool {
if e == nil {
return false
}
if len(e.Original) != len(e.Current) {
return true
}
for deviceID, originalPort := range e.Original {
if e.Current[deviceID] != originalPort {
return true
}
}
return false
}
// ApplyToConfig applies the current assignment state to a copy of the
// supplied client configuration.
//
// Existing virtual ports are always preserved. Only their RemoteDevice
// value is changed.
//
// An empty RemoteDevice means that the virtual port currently has no
// server-device assignment.
//
// The original configuration is not modified.
func (e *AssignmentEditor) ApplyToConfig(
cfg *config.ClientConfig,
) *config.ClientConfig {
if e == nil || cfg == nil {
return nil
}
result := *cfg
result.VirtualPorts = make(
[]config.VirtualPortConfig,
len(cfg.VirtualPorts),
)
for i, virtualPort := range cfg.VirtualPorts {
result.VirtualPorts[i] = virtualPort
// Every existing virtual port remains in the configuration.
// It becomes unassigned unless the current GUI state assigns it
// to a server device.
result.VirtualPorts[i].RemoteDevice = ""
for deviceID, port := range e.Current {
if port == virtualPort.Port {
result.VirtualPorts[i].RemoteDevice = deviceID
break
}
}
}
return &result
}
///////////////////////////////////////////////////////////////////////////////
// Build assignments
///////////////////////////////////////////////////////////////////////////////
// BuildAssignments erzeugt die Zuordnungsansicht aus der bestehenden
// Client-Konfiguration und der aktuell vom Server gelieferten Geräteliste.
//
// Es werden ausschließlich die in der Client-Konfiguration vorhandenen
// lokalen virtuellen Ports berücksichtigt.
func BuildAssignments(
cfg *config.ClientConfig,
devices []transport.RemoteDeviceInfo,
) []DeviceAssignment {
if cfg == nil {
return nil
}
assignments := make(
[]DeviceAssignment,
0,
len(cfg.VirtualPorts),
)
for _, virtualPort := range cfg.VirtualPorts {
assignments = append(
assignments,
DeviceAssignment{
LocalPort: virtualPort.Port,
RemoteDevice: virtualPort.RemoteDevice,
Available: remoteDeviceExists(
virtualPort.RemoteDevice,
devices,
),
},
)
}
return assignments
}
///////////////////////////////////////////////////////////////////////////////
// Unassigned devices
///////////////////////////////////////////////////////////////////////////////
// UnassignedDevices liefert die vom Server angebotenen Geräte, die aktuell
// keiner lokalen virtuellen Schnittstelle zugeordnet sind.
func UnassignedDevices(
cfg *config.ClientConfig,
devices []transport.RemoteDeviceInfo,
) []transport.RemoteDeviceInfo {
assigned := assignedRemoteDevices(
cfg,
)
unassigned := make(
[]transport.RemoteDeviceInfo,
0,
)
for _, device := range devices {
if assigned[device.ID] {
continue
}
unassigned = append(
unassigned,
device,
)
}
return unassigned
}
///////////////////////////////////////////////////////////////////////////////
// Internal helpers
///////////////////////////////////////////////////////////////////////////////
// portUsedByOtherDevice prüft, ob ein Port momentan einem anderen
// Server-Gerät zugeordnet ist.
func (e *AssignmentEditor) portUsedByOtherDevice(
port string,
deviceID string,
) bool {
for otherDeviceID, assignedPort := range e.Current {
if otherDeviceID == deviceID {
continue
}
if assignedPort == port {
return true
}
}
return false
}
// assignedRemoteDevices erzeugt eine Menge der Remote-Geräte, die bereits
// einer lokalen virtuellen Schnittstelle zugeordnet sind.
func assignedRemoteDevices(
cfg *config.ClientConfig,
) map[string]bool {
assigned := make(
map[string]bool,
)
if cfg == nil {
return assigned
}
for _, virtualPort := range cfg.VirtualPorts {
if virtualPort.RemoteDevice == "" {
continue
}
assigned[virtualPort.RemoteDevice] = true
}
return assigned
}
// remoteDeviceExists prüft, ob ein Remote-Gerät mit der angegebenen ID in
// der aktuellen Geräteliste des Servers vorhanden ist.
func remoteDeviceExists(
id string,
devices []transport.RemoteDeviceInfo,
) bool {
for _, device := range devices {
if device.ID == id {
return true
}
}
return false
}
// cloneAssignments erzeugt eine unabhängige Kopie einer Zuordnungskarte.
func cloneAssignments(
assignments map[string]string,
) map[string]string {
clone := make(
map[string]string,
len(assignments),
)
for deviceID, port := range assignments {
clone[deviceID] = port
}
return clone
}
// containsString prüft, ob ein String in einer Liste enthalten ist.
func containsString(
values []string,
value string,
) bool {
for _, current := range values {
if current == value {
return true
}
}
return false
}

View file

@ -0,0 +1,374 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/gui/assignment_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für die GUI-unabhängige Aufbereitung der Zuordnung zwischen lokalen
* virtuellen seriellen Schnittstellen und den vom rs2322tcp-Server
* angebotenen Geräten.
* ============================================================================
*/
package gui
import (
"testing"
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
///////////////////////////////////////////////////////////////////////////////
// Test helpers
///////////////////////////////////////////////////////////////////////////////
// testClientConfig creates a client configuration containing the supplied
// local-to-remote assignments.
func testClientConfig(
assignments ...config.VirtualPortConfig,
) *config.ClientConfig {
return &config.ClientConfig{
VirtualPorts: assignments,
}
}
// testRemoteDevices creates a list of remote devices from their IDs.
func testRemoteDevices(ids ...string) []transport.RemoteDeviceInfo {
devices := make([]transport.RemoteDeviceInfo, 0, len(ids))
for _, id := range ids {
devices = append(devices, transport.RemoteDeviceInfo{
ID: id,
Name: id,
})
}
return devices
}
///////////////////////////////////////////////////////////////////////////////
// BuildAssignments tests
///////////////////////////////////////////////////////////////////////////////
func TestBuildAssignments(t *testing.T) {
cfg := testClientConfig(
config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
RemoteDevice: "radio",
},
config.VirtualPortConfig{
Port: "/dev/ttyUSB101",
RemoteDevice: "rotor",
},
)
devices := testRemoteDevices(
"radio",
"rotor",
"gps",
)
assignments := BuildAssignments(cfg, devices)
if len(assignments) != 2 {
t.Fatalf(
"BuildAssignments() returned %d assignments, want 2",
len(assignments),
)
}
if assignments[0].LocalPort != "/dev/ttyUSB100" {
t.Errorf(
"assignments[0].LocalPort = %q, want %q",
assignments[0].LocalPort,
"/dev/ttyUSB100",
)
}
if assignments[0].RemoteDevice != "radio" {
t.Errorf(
"assignments[0].RemoteDevice = %q, want %q",
assignments[0].RemoteDevice,
"radio",
)
}
if !assignments[0].Available {
t.Error("assignments[0].Available = false, want true")
}
if assignments[1].LocalPort != "/dev/ttyUSB101" {
t.Errorf(
"assignments[1].LocalPort = %q, want %q",
assignments[1].LocalPort,
"/dev/ttyUSB101",
)
}
if assignments[1].RemoteDevice != "rotor" {
t.Errorf(
"assignments[1].RemoteDevice = %q, want %q",
assignments[1].RemoteDevice,
"rotor",
)
}
if !assignments[1].Available {
t.Error("assignments[1].Available = false, want true")
}
}
func TestBuildAssignmentsUnavailableDevice(t *testing.T) {
cfg := testClientConfig(
config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
RemoteDevice: "radio",
},
config.VirtualPortConfig{
Port: "/dev/ttyUSB101",
RemoteDevice: "rotor",
},
)
devices := testRemoteDevices("rotor")
assignments := BuildAssignments(cfg, devices)
if len(assignments) != 2 {
t.Fatalf(
"BuildAssignments() returned %d assignments, want 2",
len(assignments),
)
}
if assignments[0].RemoteDevice != "radio" {
t.Errorf(
"assignments[0].RemoteDevice = %q, want %q",
assignments[0].RemoteDevice,
"radio",
)
}
if assignments[0].Available {
t.Error("radio assignment marked available, want unavailable")
}
if assignments[1].RemoteDevice != "rotor" {
t.Errorf(
"assignments[1].RemoteDevice = %q, want %q",
assignments[1].RemoteDevice,
"rotor",
)
}
if !assignments[1].Available {
t.Error("rotor assignment marked unavailable, want available")
}
}
func TestBuildAssignmentsNilConfig(t *testing.T) {
devices := testRemoteDevices("radio")
assignments := BuildAssignments(nil, devices)
if assignments != nil {
t.Fatalf(
"BuildAssignments(nil, ...) = %#v, want nil",
assignments,
)
}
}
func TestBuildAssignmentsEmptyConfig(t *testing.T) {
cfg := testClientConfig()
devices := testRemoteDevices(
"radio",
"rotor",
)
assignments := BuildAssignments(cfg, devices)
if len(assignments) != 0 {
t.Fatalf(
"BuildAssignments() returned %d assignments, want 0",
len(assignments),
)
}
}
///////////////////////////////////////////////////////////////////////////////
// UnassignedDevices tests
///////////////////////////////////////////////////////////////////////////////
func TestUnassignedDevices(t *testing.T) {
cfg := testClientConfig(
config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
RemoteDevice: "radio",
},
config.VirtualPortConfig{
Port: "/dev/ttyUSB101",
RemoteDevice: "rotor",
},
)
devices := testRemoteDevices(
"radio",
"rotor",
"gps",
"tnc",
)
unassigned := UnassignedDevices(cfg, devices)
if len(unassigned) != 2 {
t.Fatalf(
"UnassignedDevices() returned %d devices, want 2",
len(unassigned),
)
}
if unassigned[0].ID != "gps" {
t.Errorf(
"unassigned[0].ID = %q, want %q",
unassigned[0].ID,
"gps",
)
}
if unassigned[1].ID != "tnc" {
t.Errorf(
"unassigned[1].ID = %q, want %q",
unassigned[1].ID,
"tnc",
)
}
}
func TestUnassignedDevicesAllAssigned(t *testing.T) {
cfg := testClientConfig(
config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
RemoteDevice: "radio",
},
config.VirtualPortConfig{
Port: "/dev/ttyUSB101",
RemoteDevice: "rotor",
},
)
devices := testRemoteDevices(
"radio",
"rotor",
)
unassigned := UnassignedDevices(cfg, devices)
if len(unassigned) != 0 {
t.Fatalf(
"UnassignedDevices() returned %d devices, want 0",
len(unassigned),
)
}
}
func TestUnassignedDevicesEmptyConfig(t *testing.T) {
cfg := testClientConfig()
devices := testRemoteDevices(
"radio",
"rotor",
)
unassigned := UnassignedDevices(cfg, devices)
if len(unassigned) != 2 {
t.Fatalf(
"UnassignedDevices() returned %d devices, want 2",
len(unassigned),
)
}
}
func TestUnassignedDevicesNilConfig(t *testing.T) {
devices := testRemoteDevices(
"radio",
"rotor",
)
unassigned := UnassignedDevices(nil, devices)
if len(unassigned) != 2 {
t.Fatalf(
"UnassignedDevices(nil, ...) returned %d devices, want 2",
len(unassigned),
)
}
}
func TestUnassignedDevicesEmptyServerList(t *testing.T) {
cfg := testClientConfig(
config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
RemoteDevice: "radio",
},
)
unassigned := UnassignedDevices(cfg, nil)
if len(unassigned) != 0 {
t.Fatalf(
"UnassignedDevices() returned %d devices, want 0",
len(unassigned),
)
}
}
///////////////////////////////////////////////////////////////////////////////
// Existing configuration does not change
///////////////////////////////////////////////////////////////////////////////
func TestAssignmentFunctionsDoNotModifyConfig(t *testing.T) {
cfg := testClientConfig(
config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
RemoteDevice: "radio",
},
)
devices := testRemoteDevices(
"radio",
"rotor",
"gps",
)
_ = BuildAssignments(cfg, devices)
_ = UnassignedDevices(cfg, devices)
if len(cfg.VirtualPorts) != 1 {
t.Fatalf(
"configuration contains %d virtual ports, want 1",
len(cfg.VirtualPorts),
)
}
if cfg.VirtualPorts[0].Port != "/dev/ttyUSB100" {
t.Errorf(
"configuration port = %q, want %q",
cfg.VirtualPorts[0].Port,
"/dev/ttyUSB100",
)
}
if cfg.VirtualPorts[0].RemoteDevice != "radio" {
t.Errorf(
"configuration remote device = %q, want %q",
cfg.VirtualPorts[0].RemoteDevice,
"radio",
)
}
}

View file

@ -16,6 +16,7 @@ package serial
import (
"fmt"
"io"
"sync"
bugserial "go.bug.st/serial"
@ -28,6 +29,7 @@ import (
// Connection represents an opened serial connection.
type Connection struct {
mu sync.Mutex
port bugserial.Port
}
@ -126,20 +128,36 @@ func createMode(device config.DeviceConfig) (*bugserial.Mode, error) {
// Read reads data from the serial connection.
func (c *Connection) Read(p []byte) (int, error) {
if c == nil || c.port == nil {
if c == nil {
return 0, io.ErrClosedPipe
}
return c.port.Read(p)
c.mu.Lock()
port := c.port
c.mu.Unlock()
if port == nil {
return 0, io.ErrClosedPipe
}
return port.Read(p)
}
// Write writes data to the serial connection.
func (c *Connection) Write(p []byte) (int, error) {
if c == nil || c.port == nil {
if c == nil {
return 0, io.ErrClosedPipe
}
return c.port.Write(p)
c.mu.Lock()
port := c.port
c.mu.Unlock()
if port == nil {
return 0, io.ErrClosedPipe
}
return port.Write(p)
}
///////////////////////////////////////////////////////////////////////////////
@ -148,12 +166,21 @@ func (c *Connection) Write(p []byte) (int, error) {
// Close closes the serial connection.
func (c *Connection) Close() error {
if c == nil || c.port == nil {
if c == nil {
return nil
}
err := c.port.Close()
c.mu.Lock()
if c.port == nil {
c.mu.Unlock()
return nil
}
port := c.port
c.port = nil
return err
c.mu.Unlock()
return port.Close()
}

View file

@ -336,6 +336,7 @@ func (s *ControlServer) runDataListener(
dataConnection, err := NewDataConnection(
tcpConn,
serialConn,
s.config.HardwareErrorResponse,
)
if err != nil {
log.Printf(
@ -351,6 +352,11 @@ func (s *ControlServer) runDataListener(
continue
}
dataConnection.SetSerialMonitor(
s.config.SerialMonitor,
device.SerialPort,
)
if err := session.AddDataConnection(
device.ID,
dataConnection,
@ -362,6 +368,7 @@ func (s *ControlServer) runDataListener(
err,
)
_ = dataConnection.Close()
continue
}

View file

@ -40,6 +40,7 @@ func testServerConfig() *config.ServerConfig {
Address: "127.0.0.1",
Port: 5000,
},
HardwareErrorResponse: testHardwareErrorResponse,
Devices: []config.DeviceConfig{
{
ID: "radio",
@ -451,6 +452,7 @@ func TestControlServerDataConnection(t *testing.T) {
Address: "127.0.0.1",
Port: 5000,
},
HardwareErrorResponse: testHardwareErrorResponse,
Devices: []config.DeviceConfig{
{
ID: "radio",

View file

@ -1,7 +1,7 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: data_connection.go
* Datei.......: internal/server/data_connection.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
@ -16,6 +16,7 @@ package server
import (
"fmt"
"io"
"log"
"net"
"sync"
)
@ -31,12 +32,20 @@ import (
// TCP -> Serial
// TCP <- Serial
//
// If writing TCP data to the serial device fails, the configured hardware
// error response is sent back over the TCP connection before the connection
// is terminated.
//
// The serial side is represented by an io.ReadWriteCloser so that this
// server component does not depend directly on the concrete serial
// implementation.
type DataConnection struct {
tcp net.Conn
serial io.ReadWriteCloser
tcp net.Conn
serial io.ReadWriteCloser
hardwareErrorResponse string
serialMonitor bool
serialPort string
closeOnce sync.Once
closeErr error
@ -50,6 +59,7 @@ type DataConnection struct {
func NewDataConnection(
tcp net.Conn,
serial io.ReadWriteCloser,
hardwareErrorResponse string,
) (*DataConnection, error) {
if tcp == nil {
return nil, fmt.Errorf("TCP connection is nil")
@ -59,9 +69,14 @@ func NewDataConnection(
return nil, fmt.Errorf("serial connection is nil")
}
if hardwareErrorResponse == "" {
return nil, fmt.Errorf("hardware error response is empty")
}
return &DataConnection{
tcp: tcp,
serial: serial,
tcp: tcp,
serial: serial,
hardwareErrorResponse: hardwareErrorResponse,
}, nil
}
@ -87,6 +102,153 @@ func (c *DataConnection) SerialConn() io.ReadWriteCloser {
return c.serial
}
// SetSerialMonitor enables or disables the data monitor.
//
// If enabled, transmitted and received data is written to the server log.
// serialPort is used only for identifying the physical interface in the
// monitor output.
func (c *DataConnection) SetSerialMonitor(
enabled bool,
serialPort string,
) {
if c == nil {
return
}
c.serialMonitor = enabled
c.serialPort = serialPort
}
///////////////////////////////////////////////////////////////////////////////
// Data monitor
///////////////////////////////////////////////////////////////////////////////
// logTCPData writes TCP data to the server log.
func (c *DataConnection) logTCPData(
direction string,
data []byte,
) {
if c == nil || !c.serialMonitor || len(data) == 0 {
return
}
log.Printf(
"%s % X",
direction,
data,
)
}
// logSerialData writes serial data to the server log.
func (c *DataConnection) logSerialData(
direction string,
data []byte,
) {
if c == nil || !c.serialMonitor || len(data) == 0 {
return
}
log.Printf(
"%s [SERIAL %s] % X",
direction,
c.serialPort,
data,
)
}
///////////////////////////////////////////////////////////////////////////////
// Data transfer
///////////////////////////////////////////////////////////////////////////////
// copyTCPToSerial transfers data from the TCP connection to the serial
// device.
//
// If writing to the serial device fails, the configured hardware error
// response is sent back to the TCP client before the transfer terminates.
func (c *DataConnection) copyTCPToSerial() error {
if c == nil {
return fmt.Errorf("data connection is nil")
}
buffer := make([]byte, 32*1024)
for {
n, err := c.tcp.Read(buffer)
if n > 0 {
c.logTCPData("TCP RX", buffer[:n])
written, writeErr := c.serial.Write(buffer[:n])
if writeErr != nil {
_, _ = io.WriteString(
c.tcp,
c.hardwareErrorResponse,
)
return writeErr
}
if written != n {
_, _ = io.WriteString(
c.tcp,
c.hardwareErrorResponse,
)
return io.ErrShortWrite
}
c.logSerialData("SERIAL TX", buffer[:n])
}
if err != nil {
return err
}
}
}
// copySerialToTCP transfers data from the serial device to the TCP
// connection.
func (c *DataConnection) copySerialToTCP() error {
if c == nil {
return fmt.Errorf("data connection is nil")
}
buffer := make([]byte, 32*1024)
for {
n, err := c.serial.Read(buffer)
if n > 0 {
c.logSerialData("SERIAL RX", buffer[:n])
written := 0
for written < n {
count, writeErr := c.tcp.Write(
buffer[written:n],
)
written += count
if writeErr != nil {
return writeErr
}
if count == 0 {
return io.ErrShortWrite
}
}
c.logTCPData("TCP TX", buffer[:n])
}
if err != nil {
return err
}
}
}
///////////////////////////////////////////////////////////////////////////////
// Run
///////////////////////////////////////////////////////////////////////////////
@ -96,6 +258,10 @@ func (c *DataConnection) SerialConn() io.ReadWriteCloser {
// Run blocks until one of the two transfer directions terminates.
// The other direction is then stopped and both connections are closed.
//
// If writing data from TCP to the serial device fails, the configured
// hardware error response is sent to the TCP client before the connection
// is closed.
//
// The first non-EOF transfer error is returned.
func (c *DataConnection) Run() error {
if c == nil {
@ -111,15 +277,13 @@ func (c *DataConnection) Run() error {
go func() {
defer wg.Done()
_, err := io.Copy(c.serial, c.tcp)
errCh <- err
errCh <- c.copyTCPToSerial()
}()
go func() {
defer wg.Done()
_, err := io.Copy(c.tcp, c.serial)
errCh <- err
errCh <- c.copySerialToTCP()
}()
err := <-errCh

View file

@ -14,6 +14,7 @@ package server_test
import (
"bytes"
"errors"
"io"
"net"
"sync"
@ -31,6 +32,8 @@ type testSerialConnection struct {
reader *bytes.Reader
writer bytes.Buffer
writeErr error
mu sync.Mutex
closed bool
@ -45,6 +48,14 @@ func newTestSerialConnection(data []byte) *testSerialConnection {
}
}
func newFailingTestSerialConnection(writeErr error) *testSerialConnection {
return &testSerialConnection{
reader: bytes.NewReader(nil),
writeErr: writeErr,
closeCh: make(chan struct{}),
}
}
func (s *testSerialConnection) Read(p []byte) (int, error) {
s.mu.Lock()
@ -76,6 +87,10 @@ func (s *testSerialConnection) Write(p []byte) (int, error) {
return 0, io.ErrClosedPipe
}
if s.writeErr != nil {
return 0, s.writeErr
}
return s.writer.Write(p)
}
@ -116,6 +131,7 @@ func TestNewDataConnection(t *testing.T) {
connection, err := server.NewDataConnection(
tcpServer,
serial,
testHardwareErrorResponse,
)
if err != nil {
t.Fatalf("NewDataConnection() failed: %v", err)
@ -136,6 +152,7 @@ func TestNewDataConnectionRejectsNilTCP(t *testing.T) {
connection, err := server.NewDataConnection(
nil,
serial,
testHardwareErrorResponse,
)
if err == nil {
@ -155,6 +172,7 @@ func TestNewDataConnectionRejectsNilSerial(t *testing.T) {
connection, err := server.NewDataConnection(
tcpServer,
nil,
testHardwareErrorResponse,
)
if err == nil {
@ -179,6 +197,7 @@ func TestDataConnectionTCPToSerial(t *testing.T) {
connection, err := server.NewDataConnection(
tcpServer,
serial,
testHardwareErrorResponse,
)
if err != nil {
t.Fatalf("NewDataConnection() failed: %v", err)
@ -223,6 +242,60 @@ func TestDataConnectionTCPToSerial(t *testing.T) {
}
}
///////////////////////////////////////////////////////////////////////////////
// TCP -> Serial error
///////////////////////////////////////////////////////////////////////////////
func TestDataConnectionTCPToSerialWriteError(t *testing.T) {
tcpServer, tcpClient := net.Pipe()
defer tcpClient.Close()
writeErr := errors.New("hardware write failed")
serial := newFailingTestSerialConnection(writeErr)
connection, err := server.NewDataConnection(
tcpServer,
serial,
testHardwareErrorResponse,
)
if err != nil {
t.Fatalf("NewDataConnection() failed: %v", err)
}
done := make(chan error, 1)
go func() {
done <- connection.Run()
}()
testData := []byte("hello unavailable hardware")
if _, err := tcpClient.Write(testData); err != nil {
t.Fatalf("TCP Write() failed: %v", err)
}
errorResponse := make([]byte, len(testHardwareErrorResponse))
if _, err := io.ReadFull(tcpClient, errorResponse); err != nil {
t.Fatalf("TCP Read() failed: %v", err)
}
if string(errorResponse) != testHardwareErrorResponse {
t.Fatalf(
"error response = %q, want %q",
string(errorResponse),
testHardwareErrorResponse,
)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("DataConnection.Run() did not terminate")
}
}
///////////////////////////////////////////////////////////////////////////////
// Serial -> TCP
///////////////////////////////////////////////////////////////////////////////
@ -238,6 +311,7 @@ func TestDataConnectionSerialToTCP(t *testing.T) {
connection, err := server.NewDataConnection(
tcpServer,
serial,
testHardwareErrorResponse,
)
if err != nil {
t.Fatalf("NewDataConnection() failed: %v", err)
@ -285,6 +359,7 @@ func TestDataConnectionClose(t *testing.T) {
connection, err := server.NewDataConnection(
tcpServer,
serial,
testHardwareErrorResponse,
)
if err != nil {
t.Fatalf("NewDataConnection() failed: %v", err)

View file

@ -38,8 +38,9 @@ type SerialFactory func() (io.ReadWriteCloser, error)
// DataHandler accepts TCP data connections and connects them to a serial
// device.
type DataHandler struct {
listener *DataListener
serialFactory SerialFactory
listener *DataListener
serialFactory SerialFactory
hardwareErrorResponse string
}
///////////////////////////////////////////////////////////////////////////////
@ -53,6 +54,7 @@ type DataHandler struct {
func NewDataHandler(
listener *DataListener,
serialFactory SerialFactory,
hardwareErrorResponse string,
) (*DataHandler, error) {
if listener == nil {
return nil, fmt.Errorf("data listener is nil")
@ -62,9 +64,14 @@ func NewDataHandler(
return nil, fmt.Errorf("serial factory is nil")
}
if hardwareErrorResponse == "" {
return nil, fmt.Errorf("hardware error response is empty")
}
return &DataHandler{
listener: listener,
serialFactory: serialFactory,
listener: listener,
serialFactory: serialFactory,
hardwareErrorResponse: hardwareErrorResponse,
}, nil
}
@ -114,6 +121,7 @@ func (h *DataHandler) handleConnection(tcpConn net.Conn) {
dataConnection, err := NewDataConnection(
tcpConn,
serialConn,
h.hardwareErrorResponse,
)
if err != nil {
log.Printf("create data connection: %v", err)

View file

@ -24,6 +24,12 @@ import (
"git.lang-dieter.de/rs2322tcp/internal/server"
)
///////////////////////////////////////////////////////////////////////////////
// Test constants
///////////////////////////////////////////////////////////////////////////////
const testHardwareErrorResponse = "TEST ERROR RESPONSE"
///////////////////////////////////////////////////////////////////////////////
// Test serial connection
///////////////////////////////////////////////////////////////////////////////
@ -119,6 +125,7 @@ func TestNewDataHandler(t *testing.T) {
func() (io.ReadWriteCloser, error) {
return serial, nil
},
testHardwareErrorResponse,
)
if err != nil {
t.Fatalf("NewDataHandler() failed: %v", err)
@ -135,6 +142,7 @@ func TestNewDataHandlerRejectsNilListener(t *testing.T) {
func() (io.ReadWriteCloser, error) {
return newHandlerTestSerial(nil), nil
},
testHardwareErrorResponse,
)
if err == nil {
@ -156,6 +164,7 @@ func TestNewDataHandlerRejectsNilFactory(t *testing.T) {
handler, err := server.NewDataHandler(
listener,
nil,
testHardwareErrorResponse,
)
if err == nil {
@ -185,6 +194,7 @@ func TestDataHandlerTCPToSerial(t *testing.T) {
func() (io.ReadWriteCloser, error) {
return serial, nil
},
testHardwareErrorResponse,
)
if err != nil {
t.Fatalf("NewDataHandler() failed: %v", err)
@ -255,6 +265,7 @@ func TestDataHandlerSerialToTCP(t *testing.T) {
func() (io.ReadWriteCloser, error) {
return serial, nil
},
testHardwareErrorResponse,
)
if err != nil {
t.Fatalf("NewDataHandler() failed: %v", err)
@ -311,6 +322,7 @@ func TestDataHandlerSerialFactoryError(t *testing.T) {
func() (io.ReadWriteCloser, error) {
return nil, io.ErrClosedPipe
},
testHardwareErrorResponse,
)
if err != nil {
t.Fatalf("NewDataHandler() failed: %v", err)

View file

@ -0,0 +1,12 @@
@echo off
cd /d "%~dp0.."
echo ==========================================
echo rs2322tcp COM10 Testsequenz
echo ==========================================
echo.
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\test-rotor-com10.ps1"
echo.
pause

View file

@ -0,0 +1,80 @@
$port = New-Object System.IO.Ports.SerialPort COM10,600,None,8,one
try {
$port.Open()
[byte[]]$request = 0x57,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x1F,0x20
$port.Write($request, 0, $request.Length)
$response = New-Object System.Collections.Generic.List[byte]
$timeout = [DateTime]::UtcNow.AddSeconds(2)
while ([DateTime]::UtcNow -lt $timeout) {
while ($port.BytesToRead -gt 0) {
$buffer = New-Object byte[] $port.BytesToRead
$count = $port.Read($buffer, 0, $buffer.Length)
for ($i = 0; $i -lt $count; $i++) {
$response.Add($buffer[$i])
}
}
# Auf das Startbyte 57 synchronisieren.
$start = $response.IndexOf([byte]0x57)
if ($start -ge 0 -and ($response.Count - $start) -ge 12) {
$response = [System.Collections.Generic.List[byte]](
$response.GetRange($start, 12)
)
break
}
Start-Sleep -Milliseconds 20
}
if ($response.Count -eq 12) {
$actual = ($response |
ForEach-Object { "{0:X2}" -f $_ }) -join " "
$expected = "57 07 01 06 00 01 03 06 02 00 01 20"
Write-Host "Bytes empfangen: $($response.Count)"
Write-Host "Antwort: $actual"
if ($actual -eq $expected) {
Write-Host "TEST ERFOLGREICH"
}
else {
Write-Host "TEST FEHLGESCHLAGEN"
Write-Host "Erwartet: $expected"
}
}
else {
Write-Host "Bytes empfangen: $($response.Count)"
if ($response.Count -gt 0) {
$actual = ($response |
ForEach-Object { "{0:X2}" -f $_ }) -join " "
Write-Host "Antwort: $actual"
}
else {
Write-Host "Keine Antwort"
}
Write-Host "TEST FEHLGESCHLAGEN - keine vollstaendige Antwort gefunden"
}
}
finally {
if ($port.IsOpen) {
$port.Close()
}
$port.Dispose()
$port = $null
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}

View file

@ -0,0 +1,20 @@
@echo off
cd /d "%~dp0.."
echo ==========================================
echo rs2322tcp Windows Client Test
echo ==========================================
echo.
echo Starte normalen rs2322tcp-Client...
echo COM100 wird vom Client verwendet.
echo Die konfigurierte Startsequenz wird nach
echo erfolgreichem Verbindungsaufbau automatisch gesendet.
echo.
echo Zum Beenden: Ctrl+C
echo.
go run .\cmd\rs2322tcp-client
echo.
echo Client beendet.
pause