Compare commits

...

12 commits
v0.0.8 ... main

32 changed files with 2701 additions and 575 deletions

View file

@ -2,6 +2,117 @@
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

243
README.md
View file

@ -159,11 +159,13 @@ Die gemeinsame Client-Architektur ist bereits auf eine
plattformabhängige Implementierung der virtuellen seriellen
Schnittstelle vorbereitet.
Die Windows-Implementierung der virtuellen seriellen Schnittstelle ist
noch nicht vollständig implementiert.
Unter Windows wird dieselbe gemeinsame Client- und Konfigurationslogik
verwendet. Für Tests können vorhandene virtuelle COM-Port-Verbindungen
verwendet werden.
Der spätere Windows-Client soll dieselbe gemeinsame Client- und
Konfigurationslogik verwenden.
Mit Version `v0.0.11` wurde der Windows-Client mit funktionierender
Fyne-GUI sowie der automatischen Übertragung der konfigurierten Rotor-
Startbytes praktisch getestet.
## Server
@ -414,6 +416,227 @@ 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
@ -423,8 +646,9 @@ 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.
Während der Entwicklung kann `socat` als zusätzliches Werkzeug für Tests
und Diagnose eingesetzt werden.
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
@ -659,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:
@ -782,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/

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

@ -23,10 +23,11 @@ package main
import (
"fmt"
"image/color"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/theme"
"image/color"
"git.lang-dieter.de/rs2322tcp/internal/gui"
"git.lang-dieter.de/rs2322tcp/internal/version"
@ -113,11 +114,6 @@ func main() {
return
}
// Das technische Client-System wird nach dem Erzeugen des Fensters
// asynchron gestartet. Dadurch bleibt die GUI auch während des
// Verbindungsaufbaus sofort sichtbar und bedienbar.
guiApplication.Start()
// SetOnClosed gehört zum Fyne-Fenster, nicht zur Fyne-Anwendung.
//
// Beim Schließen des Hauptfensters wird die technische Client-
@ -129,6 +125,16 @@ func main() {
},
)
// ShowAndRun zeigt das Fenster und startet den Fyne-Eventloop.
guiApplication.ShowAndRun()
}
// 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

@ -10,11 +10,9 @@
"virtual_ports": [
{
"port": "/dev/ttyUSB100",
"remote_device": "radio"
},
{
"port": "/dev/ttyUSB101",
"remote_device": "rotor"
"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

@ -11,66 +11,24 @@
* und dem zugehörigen entfernten Gerät.
*
* Die konfigurierte lokale virtuelle Schnittstelle wird dabei exakt
* übernommen. Eine Konfiguration wie
* übernommen.
*
* /dev/ttyUSB100 -> radio
*
* führt somit technisch auch zur Erzeugung beziehungsweise Verwendung von
* /dev/ttyUSB100 und nicht einfach zum nächsten freien Port.
* Optional können nach dem Aufbau der Datenverbindung konfigurierte
* Startbytes an das entfernte Gerät gesendet werden.
* ============================================================================
*/
package client
import (
"encoding/hex"
"fmt"
"net"
"strconv"
"strings"
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
///////////////////////////////////////////////////////////////////////////////
// Virtual port configuration
///////////////////////////////////////////////////////////////////////////////
// virtualPortNumber converts a configured /dev/ttyUSBxxx path into its
// numeric virtual port number.
//
// The client configuration stores the complete Linux device path, while
// VirtualPortManager works with the numeric port number. Only the exact
// /dev/ttyUSB<number> format is accepted here.
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
}
///////////////////////////////////////////////////////////////////////////////
// Virtual port connection
///////////////////////////////////////////////////////////////////////////////
@ -83,6 +41,11 @@ func virtualPortNumber(path string) (int, error) {
// 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,
@ -128,9 +91,63 @@ func connectVirtualPort(
)
}
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
///////////////////////////////////////////////////////////////////////////////

View file

@ -144,7 +144,7 @@ func TestConnectVirtualPortNilClient(t *testing.T) {
)
cfg := config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
@ -172,7 +172,7 @@ func TestConnectVirtualPortNilClient(t *testing.T) {
func TestConnectVirtualPortNilManager(t *testing.T) {
cfg := config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
@ -209,7 +209,7 @@ func TestConnectVirtualPortUnknownDevice(t *testing.T) {
client := newTestClient(t)
cfg := config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
Port: virtualPortPath(100),
RemoteDevice: "unknown",
}
@ -249,7 +249,7 @@ func TestConnectVirtualPortClosesPortWhenDataConnectionFails(t *testing.T) {
client := newTestClient(t)
cfg := config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
@ -323,7 +323,7 @@ func TestConnectVirtualPortSuccess(t *testing.T) {
)
cfg := config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
@ -348,11 +348,11 @@ func TestConnectVirtualPortSuccess(t *testing.T) {
defer virtualPort.Close()
defer dataConn.Close()
if virtualPort.Path() != "/dev/ttyUSB100" {
if virtualPort.Path() != virtualPortPath(100) {
t.Fatalf(
"virtual port path = %q, want %q",
virtualPort.Path(),
"/dev/ttyUSB100",
virtualPortPath(100),
)
}
@ -386,7 +386,7 @@ func TestConnectVirtualPortUsesConfiguredPort(t *testing.T) {
)
cfg := config.VirtualPortConfig{
Port: "/dev/ttyUSB150",
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
@ -411,24 +411,29 @@ func TestConnectVirtualPortUsesConfiguredPort(t *testing.T) {
defer virtualPort.Close()
defer dataConn.Close()
if virtualPort.Path() != "/dev/ttyUSB150" {
if virtualPort.Path() != virtualPortPath(100) {
t.Fatalf(
"virtual port path = %q, want %q",
virtualPort.Path(),
"/dev/ttyUSB150",
virtualPortPath(100),
)
}
// Port 100 must still be available. This proves that the connection
// did not simply take the first free port from the configured range.
// 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 != 100 {
if number != 101 {
t.Fatalf(
"first free port after connection = %d, want 100",
"first free port after connection = %d, want 101",
number,
)
}
@ -463,7 +468,7 @@ func TestConnectBridgeSuccess(t *testing.T) {
)
cfg := config.VirtualPortConfig{
Port: "/dev/ttyUSB100",
Port: virtualPortPath(100),
RemoteDevice: "radio",
}
@ -500,11 +505,11 @@ func TestConnectBridgeSuccess(t *testing.T) {
t.Fatal("bridge TCP endpoint does not match data connection")
}
if virtualPort.Path() != "/dev/ttyUSB100" {
if virtualPort.Path() != virtualPortPath(100) {
t.Fatalf(
"virtual port path = %q, want %q",
virtualPort.Path(),
"/dev/ttyUSB100",
virtualPortPath(100),
)
}

View file

@ -205,7 +205,30 @@ func (p *ptySerial) Write(b []byte) (int, error) {
return 0, fmt.Errorf("PTY master is closed")
}
return unix.Write(fd, b)
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.

View file

@ -17,14 +17,13 @@
* Link unter ~/.rs2322tcp/virtual/.
* ============================================================================
*/
package client
import (
"fmt"
"os"
"path/filepath"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
const (
@ -77,6 +76,9 @@ func ensureVirtualPortDirectory() error {
//
// 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")
@ -95,8 +97,10 @@ func setVirtualPortLink(portPath string, target string) error {
return err
}
// Remove an existing link. The installation-owned outer /dev/ttyUSBxxx
// link is deliberately never touched here.
// 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",
@ -117,9 +121,12 @@ func setVirtualPortLink(portPath string, target string) error {
return nil
}
// removeVirtualPortLink removes the internal link for a virtual port.
// removeVirtualPortLink removes the user-owned internal link for a virtual
// port.
//
// The public /dev/ttyUSBxxx link is never modified.
// 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")
@ -140,73 +147,3 @@ func removeVirtualPortLink(portPath string) error {
return nil
}
// 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",
)
}
// 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

@ -11,19 +11,21 @@
* seriellen Ports.
*
* Der VirtualPortManager verwaltet den für rs2322tcp vorgesehenen Bereich
* von /dev/ttyUSBxxx-Schnittstellen.
* 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.
*
* Die gezielte Reservierung ist insbesondere für die aus client.json
* geladene Konfiguration erforderlich. Dadurch bleibt eine Zuordnung wie
*
* /dev/ttyUSB100 -> radio
*
* auch technisch exakt erhalten.
* ============================================================================
*/
package client
import (
@ -37,12 +39,15 @@ import (
// VirtualPortManager
///////////////////////////////////////////////////////////////////////////////
// VirtualPortManager verwaltet den reservierten Bereich virtueller
// /dev/ttyUSBxxx-Schnittstellen.
// 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
@ -51,6 +56,10 @@ type VirtualPortManager struct {
used map[int]bool
}
///////////////////////////////////////////////////////////////////////////////
// Constructor
///////////////////////////////////////////////////////////////////////////////
// NewVirtualPortManager erzeugt einen VirtualPortManager aus dem
// konfigurierten Portbereich.
func NewVirtualPortManager(
@ -63,16 +72,32 @@ func NewVirtualPortManager(
}
}
///////////////////////////////////////////////////////////////////////////////
// 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 fmt.Sprintf("/dev/ttyUSB%d", number)
return virtualPortPath(number)
}
///////////////////////////////////////////////////////////////////////////////
// Reservation
///////////////////////////////////////////////////////////////////////////////
// Reserve reserviert den nächsten freien virtuellen Port.
//
// Es wird immer mit dem kleinsten freien Port im konfigurierten Bereich
@ -141,6 +166,10 @@ func (m *VirtualPortManager) ReserveSpecific(number int) error {
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Release
///////////////////////////////////////////////////////////////////////////////
// Release gibt einen zuvor reservierten Port wieder frei.
func (m *VirtualPortManager) Release(number int) {
if m == nil {

View file

@ -9,186 +9,39 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Linux-spezifische Erzeugung und Verwaltung virtueller rs2322tcp-Ports.
* Linux-spezifische Erzeugung eines virtuellen rs2322tcp-Ports.
*
* Der öffentliche /dev/ttyUSBxxx-Eintrag wird bei der Installation
* bereitgestellt. Der Client verwaltet ausschließlich den internen Link
* unter ~/.rs2322tcp/virtual/.
* Die gemeinsame Verwaltung von ManagedVirtualPort und VirtualPortManager
* befindet sich in virtual_port_manager_open.go.
*
* Virtuelle Ports können entweder automatisch über den nächsten freien Port
* oder gezielt über eine vorgegebene Portnummer geöffnet werden.
* Unter Linux wird der PTY über newVirtualSerial() erzeugt und der interne
* Link unter ~/.rs2322tcp/virtual/ auf diesen PTY gesetzt.
* ============================================================================
*/
package client
import (
"fmt"
"sync"
)
///////////////////////////////////////////////////////////////////////////////
// Managed virtual port
///////////////////////////////////////////////////////////////////////////////
// ManagedVirtualPort verbindet einen reservierten Port mit einer
// VirtualSerial-Instanz und verwaltet deren Lebenszyklus.
//
// Der Mutex schützt den internen Port-Pointer gegen gleichzeitige Zugriffe
// aus Read, Write und Close.
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 removes the internal link, closes the PTY and releases the
// reservation.
//
// The managed port is detached from the object before the underlying PTY
// is closed. This prevents a concurrent Read or Write from accessing the
// managed port pointer after Close has taken ownership of it.
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
}
var firstErr error
if err := removeVirtualPortLink(port.Path()); err != nil {
firstErr = err
}
if err := port.Close(); err != nil && firstErr == nil {
firstErr = err
}
if manager != nil {
manager.Release(number)
}
return firstErr
}
///////////////////////////////////////////////////////////////////////////////
// Open
///////////////////////////////////////////////////////////////////////////////
// Open creates a new virtual serial port using the next free port number.
//
// The internal PTY is created by newVirtualSerial(). The internal
// ~/.rs2322tcp/virtual/ttyUSBxxx link is then pointed at the PTY.
//
// Open retains the automatic allocation behavior used by the existing
// client code.
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
}
return m.openReserved(number)
}
// OpenSpecific creates a new virtual serial port using exactly the specified
// port number.
//
// Unlike Open, this method does not select the next free port. The requested
// number is reserved explicitly and is therefore suitable for the
// VirtualPortConfig.Port value loaded from client.json.
//
// If creation of the PTY or the internal link fails, the reservation is
// released again.
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
}
return m.openReserved(number)
}
///////////////////////////////////////////////////////////////////////////////
// Internal open helper
///////////////////////////////////////////////////////////////////////////////
// openReserved creates the actual virtual serial port for an already
// openReserved creates the actual Linux virtual serial port for an already
// reserved port number.
//
// The caller must reserve the port before calling this method.
func (m *VirtualPortManager) openReserved(number int) (*ManagedVirtualPort, error) {
//
// 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()

View file

@ -9,7 +9,8 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Linux-spezifische Tests für die Erzeugung virtueller rs2322tcp-Ports.
* Linux-spezifische Tests für die Erzeugung und Verwaltung virtueller
* rs2322tcp-Ports.
* ============================================================================
*/
package client
@ -71,10 +72,20 @@ func TestVirtualPortManagerOpen(t *testing.T) {
t.Fatalf("Close() failed: %v", err)
}
if _, err := os.Lstat(linkPath); !os.IsNotExist(err) {
targetAfterClose, err := os.Readlink(linkPath)
if err != nil {
t.Fatalf(
"internal link still exists after Close(): %q",
"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,
)
}
}
@ -125,13 +136,24 @@ func TestVirtualPortManagerOpenSpecific(t *testing.T) {
t.Fatalf("Close() failed: %v", err)
}
if _, err := os.Lstat(linkPath); !os.IsNotExist(err) {
targetAfterClose, err := os.Readlink(linkPath)
if err != nil {
t.Fatalf(
"internal link still exists after Close(): %q",
"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)

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

@ -107,8 +107,10 @@ func TestVirtualPortManagerRelease(t *testing.T) {
}
if reused != 100 {
t.Errorf("Reserve() after Release() = %d, want 100",
reused)
t.Errorf(
"Reserve() after Release() = %d, want 100",
reused,
)
}
}
@ -179,15 +181,15 @@ func TestVirtualPortManagerPortPath(t *testing.T) {
}{
{
number: 100,
want: "/dev/ttyUSB100",
want: virtualPortPath(100),
},
{
number: 123,
want: "/dev/ttyUSB123",
want: virtualPortPath(123),
},
{
number: 199,
want: "/dev/ttyUSB199",
want: virtualPortPath(199),
},
}

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

@ -7,13 +7,22 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Vergleich der in client.json definierten virtuellen Ports mit den
* tatsächlich vorhandenen virtuellen Schnittstellen.
* 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 "sort"
import (
"fmt"
"sort"
"strings"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
///////////////////////////////////////////////////////////////////////////////
// Virtual port validation
@ -62,10 +71,7 @@ func compareVirtualPorts(
continue
}
result.Missing = append(
result.Missing,
port,
)
result.Missing = append(result.Missing, port)
}
for port := range availableSet {
@ -73,10 +79,7 @@ func compareVirtualPorts(
continue
}
result.Extra = append(
result.Extra,
port,
)
result.Extra = append(result.Extra, port)
}
sort.Strings(result.Missing)
@ -84,3 +87,166 @@ func compareVirtualPorts(
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

@ -9,176 +9,98 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Linux-spezifische Prüfung der lokal vorhandenen virtuellen
* rs2322tcp-Schnittstellen gegen die Client-Konfiguration.
* 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"
"strings"
"os"
"path/filepath"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
///////////////////////////////////////////////////////////////////////////////
// Local virtual-port validation
// Existing virtual ports
///////////////////////////////////////////////////////////////////////////////
// validateLocalVirtualPorts compares the virtual ports defined in
// client.json with the public symbolic links currently available on Linux.
// existingVirtualPortLinks returns all public virtual-port paths that
// currently exist as symbolic links.
//
// The check is read-only and does not require root privileges.
// 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.
//
// 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()),
// 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,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

@ -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

@ -79,6 +79,7 @@ type VirtualPortRangeConfig struct {
type VirtualPortConfig struct {
Port string `json:"port"`
RemoteDevice string `json:"remote_device"`
StartupBytes string `json:"startup_bytes,omitempty"`
}
///////////////////////////////////////////////////////////////////////////////

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"
)
///////////////////////////////////////////////////////////////////////////////
@ -59,13 +61,15 @@ func TestLoadServer(t *testing.T) {
}
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 {
@ -75,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)
}
}
@ -121,40 +126,106 @@ 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)
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)
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",
)
}
}
@ -184,13 +255,17 @@ func TestLoadClientCustomVirtualPortRange(t *testing.T) {
}
if cfg.VirtualPortRange.First != 200 {
t.Errorf("VirtualPortRange.First = %d, want 200",
cfg.VirtualPortRange.First)
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)
t.Errorf(
"VirtualPortRange.Last = %d, want 299",
cfg.VirtualPortRange.Last,
)
}
}
@ -215,6 +290,7 @@ func TestSaveAndLoadClient(t *testing.T) {
{
Port: "COM7",
RemoteDevice: "radio",
StartupBytes: "57 00 00 1F 20",
},
{
Port: "COM8",
@ -241,9 +317,11 @@ func TestSaveAndLoadClient(t *testing.T) {
}
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 {
@ -402,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",
)
}
}
@ -425,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,
)
}
}
@ -490,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 9600",
list.Devices[0].BaudRate)
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 4800",
list.Devices[1].BaudRate)
t.Errorf(
"Devices[1].BaudRate = %d, want 4800",
list.Devices[1].BaudRate,
)
}
}
@ -561,11 +697,17 @@ func TestRemoteDeviceJSON(t *testing.T) {
}
if len(decoded.Devices) != 1 {
t.Fatalf("len(decoded.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",
)
}
}

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