From 1d3239da7172f288a453b38121c9d6763fe2b774 Mon Sep 17 00:00:00 2001 From: Dieter Lang Date: Mon, 10 Aug 2026 16:20:28 +0200 Subject: [PATCH] Add configurable local virtual serial ports --- CHANGELOG.md | 35 +++ configs/client.json | 8 +- internal/client/virtual_port.go | 70 ++++++ internal/client/virtual_port_linux.go | 140 +++++++++++ internal/client/virtual_port_linux_test.go | 153 ++++++++++++ internal/client/virtual_port_manager.go | 85 +++++++ internal/client/virtual_port_manager_linux.go | 137 +++++++++++ .../client/virtual_port_manager_linux_test.go | 221 ++++++++++++++++++ internal/client/virtual_port_manager_test.go | 216 +++++++++++++++++ internal/client/virtual_port_test.go | 117 ++++++++++ internal/client/virtual_serial.go | 4 +- internal/config/config.go | 58 ++++- internal/config/config_test.go | 114 ++++++++- 13 files changed, 1345 insertions(+), 13 deletions(-) create mode 100644 internal/client/virtual_port.go create mode 100644 internal/client/virtual_port_linux.go create mode 100644 internal/client/virtual_port_linux_test.go create mode 100644 internal/client/virtual_port_manager.go create mode 100644 internal/client/virtual_port_manager_linux.go create mode 100644 internal/client/virtual_port_manager_linux_test.go create mode 100644 internal/client/virtual_port_manager_test.go create mode 100644 internal/client/virtual_port_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ca2ae8b..fc282b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert. +## [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 diff --git a/configs/client.json b/configs/client.json index 0be587a..1bf3094 100644 --- a/configs/client.json +++ b/configs/client.json @@ -3,13 +3,17 @@ "address": "100.64.0.10", "port": 5000 }, + "virtual_port_range": { + "first": 100, + "last": 199 + }, "virtual_ports": [ { - "port": "COM7", + "port": "/dev/ttyUSB100", "remote_device": "radio" }, { - "port": "COM8", + "port": "/dev/ttyUSB101", "remote_device": "rotor" } ] diff --git a/internal/client/virtual_port.go b/internal/client/virtual_port.go new file mode 100644 index 0000000..b1c4823 --- /dev/null +++ b/internal/client/virtual_port.go @@ -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() +} diff --git a/internal/client/virtual_port_linux.go b/internal/client/virtual_port_linux.go new file mode 100644 index 0000000..8467a04 --- /dev/null +++ b/internal/client/virtual_port_linux.go @@ -0,0 +1,140 @@ +//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. +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 + } + + // Remove an existing link. The installation-owned outer /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 internal link for a virtual port. +// +// The public /dev/ttyUSBxxx link is never modified. +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 virtual port link %q: %w", + linkPath, + err, + ) + } + + return nil +} diff --git a/internal/client/virtual_port_linux_test.go b/internal/client/virtual_port_linux_test.go new file mode 100644 index 0000000..8ff7091 --- /dev/null +++ b/internal/client/virtual_port_linux_test.go @@ -0,0 +1,153 @@ +//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" + "testing" +) + +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 hasPathSuffix(path string, suffix string) bool { + return path == suffix || + len(path) > len(suffix) && + path[len(path)-len(suffix):] == suffix +} diff --git a/internal/client/virtual_port_manager.go b/internal/client/virtual_port_manager.go new file mode 100644 index 0000000..071cfa5 --- /dev/null +++ b/internal/client/virtual_port_manager.go @@ -0,0 +1,85 @@ +/* + * ============================================================================ + * 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. + * ============================================================================ + */ +package client + +import ( + "fmt" + + "git.lang-dieter.de/rs2322tcp/internal/config" +) + +/////////////////////////////////////////////////////////////////////////////// +// VirtualPortManager +/////////////////////////////////////////////////////////////////////////////// + +// VirtualPortManager verwaltet den reservierten Bereich virtueller +// /dev/ttyUSBxxx-Schnittstellen. +type VirtualPortManager struct { + first int + last int + used map[int]bool +} + +// NewVirtualPortManager erzeugt einen VirtualPortManager aus der +// konfigurierten Portbereich. +func NewVirtualPortManager(cfg config.VirtualPortRangeConfig) *VirtualPortManager { + return &VirtualPortManager{ + first: cfg.First, + last: cfg.Last, + used: make(map[int]bool), + } +} + +// PortPath erzeugt den für die externe Software sichtbaren Gerätenamen +// für eine Portnummer. +func (m *VirtualPortManager) PortPath(number int) string { + if m == nil { + return "" + } + + return fmt.Sprintf("/dev/ttyUSB%d", number) +} + +// Reserve reserviert den nächsten freien virtuellen Port. +// Es wird immer mit dem kleinsten freien Port im konfigurierten Bereich +// begonnen. +func (m *VirtualPortManager) Reserve() (int, error) { + if m == nil { + return 0, fmt.Errorf("virtual port manager is nil") + } + + 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, + ) +} + +// Release gibt einen zuvor reservierten Port wieder frei. +func (m *VirtualPortManager) Release(number int) { + if m == nil { + return + } + + delete(m.used, number) +} diff --git a/internal/client/virtual_port_manager_linux.go b/internal/client/virtual_port_manager_linux.go new file mode 100644 index 0000000..26ed6ba --- /dev/null +++ b/internal/client/virtual_port_manager_linux.go @@ -0,0 +1,137 @@ +//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 und Verwaltung virtueller rs2322tcp-Ports. + * + * Der öffentliche /dev/ttyUSBxxx-Eintrag wird bei der Installation + * bereitgestellt. Der Client verwaltet ausschließlich den internen Link + * unter ~/.rs2322tcp/virtual/. + * ============================================================================ + */ +package client + +import "fmt" + +/////////////////////////////////////////////////////////////////////////////// +// Managed virtual port +/////////////////////////////////////////////////////////////////////////////// + +// ManagedVirtualPort verbindet einen reservierten Port mit einer +// VirtualSerial-Instanz und verwaltet deren Lebenszyklus. +type ManagedVirtualPort struct { + port *VirtualPort + manager *VirtualPortManager + number int +} + +// Path returns the device path visible to the external application. +func (p *ManagedVirtualPort) Path() string { + if p == nil || 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 || p.port == nil { + return 0, fmt.Errorf("managed virtual port is nil") + } + + return p.port.Read(b) +} + +// Write writes data to the underlying virtual serial device. +func (p *ManagedVirtualPort) Write(b []byte) (int, error) { + if p == nil || p.port == nil { + return 0, fmt.Errorf("managed virtual port is nil") + } + + return p.port.Write(b) +} + +// Close removes the internal link, closes the PTY and releases the +// reservation. +func (p *ManagedVirtualPort) Close() error { + if p == nil || p.port == nil { + return nil + } + + var firstErr error + + if err := removeVirtualPortLink(p.port.Path()); err != nil { + firstErr = err + } + + if err := p.port.Close(); err != nil && firstErr == nil { + firstErr = err + } + + if p.manager != nil { + p.manager.Release(p.number) + } + + p.port = nil + + 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. +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 + } + + 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 +} diff --git a/internal/client/virtual_port_manager_linux_test.go b/internal/client/virtual_port_manager_linux_test.go new file mode 100644 index 0000000..ad02ca3 --- /dev/null +++ b/internal/client/virtual_port_manager_linux_test.go @@ -0,0 +1,221 @@ +//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 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) + } + + if _, err := os.Lstat(linkPath); !os.IsNotExist(err) { + t.Fatalf( + "internal link still exists after Close(): %q", + linkPath, + ) + } +} + +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) + } +} diff --git a/internal/client/virtual_port_manager_test.go b/internal/client/virtual_port_manager_test.go new file mode 100644 index 0000000..9a2da9c --- /dev/null +++ b/internal/client/virtual_port_manager_test.go @@ -0,0 +1,216 @@ +/* + * ============================================================================ + * 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. + * ============================================================================ + */ +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: "/dev/ttyUSB100", + }, + { + number: 123, + want: "/dev/ttyUSB123", + }, + { + number: 199, + want: "/dev/ttyUSB199", + }, + } + + 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 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") + } + + manager.Release(100) +} diff --git a/internal/client/virtual_port_test.go b/internal/client/virtual_port_test.go new file mode 100644 index 0000000..d7eba7e --- /dev/null +++ b/internal/client/virtual_port_test.go @@ -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) + } +} diff --git a/internal/client/virtual_serial.go b/internal/client/virtual_serial.go index 513e35d..e3879de 100644 --- a/internal/client/virtual_serial.go +++ b/internal/client/virtual_serial.go @@ -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. diff --git a/internal/config/config.go b/internal/config/config.go index 1d6765e..fa52cfe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 @@ -50,8 +50,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,6 +62,13 @@ 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. type VirtualPortConfig struct { @@ -68,6 +76,28 @@ type VirtualPortConfig struct { RemoteDevice string `json:"remote_device"` } +/////////////////////////////////////////////////////////////////////////////// +// 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 +) + +// DefaultVirtualPortRange returns the default virtual port range. +func DefaultVirtualPortRange() VirtualPortRangeConfig { + return VirtualPortRangeConfig{ + First: DefaultVirtualPortFirst, + Last: DefaultVirtualPortLast, + } +} + /////////////////////////////////////////////////////////////////////////////// // JSON loading /////////////////////////////////////////////////////////////////////////////// @@ -95,6 +125,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 +161,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 } @@ -189,6 +229,16 @@ 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 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5e59cf2..c09cf95 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -122,6 +122,16 @@ func TestLoadClient(t *testing.T) { 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)) @@ -140,6 +150,42 @@ func TestLoadClient(t *testing.T) { } } +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) + } +} + /////////////////////////////////////////////////////////////////////////////// // Save and reload /////////////////////////////////////////////////////////////////////////////// @@ -153,6 +199,10 @@ func TestSaveAndLoadClient(t *testing.T) { Address: "100.64.0.10", Port: 5000, }, + VirtualPortRange: config.VirtualPortRangeConfig{ + First: 200, + Last: 299, + }, VirtualPorts: []config.VirtualPortConfig{ { Port: "COM7", @@ -178,6 +228,10 @@ 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", len(loaded.VirtualPorts), @@ -228,6 +282,7 @@ func TestClientValidationDuplicateVirtualPort(t *testing.T) { Server: config.ServerConnectionConfig{ Port: 5000, }, + VirtualPortRange: config.DefaultVirtualPortRange(), VirtualPorts: []config.VirtualPortConfig{ { Port: "COM7", @@ -262,6 +317,7 @@ func TestClientValidationInvalidPort(t *testing.T) { Server: config.ServerConnectionConfig{ Port: 0, }, + VirtualPortRange: config.DefaultVirtualPortRange(), } if err := cfg.Validate(); err == nil { @@ -269,6 +325,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 /////////////////////////////////////////////////////////////////////////////// @@ -393,13 +497,13 @@ func TestServerRemoteDevices(t *testing.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,7 +553,7 @@ 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" {