Add configurable local virtual serial ports

This commit is contained in:
Dieter Lang 2026-08-10 16:20:28 +02:00
parent ea4499b93f
commit 1d3239da71
13 changed files with 1345 additions and 13 deletions

View file

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

View file

@ -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"
}
]

View file

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

View file

@ -0,0 +1,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
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
@ -51,6 +51,7 @@ type DeviceConfig struct {
// ClientConfig contains the complete client configuration.
type ClientConfig struct {
Server ServerConnectionConfig `json:"server"`
VirtualPortRange VirtualPortRangeConfig `json:"virtual_port_range"`
VirtualPorts []VirtualPortConfig `json:"virtual_ports"`
}
@ -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 {

View file

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