diff --git a/internal/client/connection.go b/internal/client/connection.go index ffc3c77..fc16f38 100644 --- a/internal/client/connection.go +++ b/internal/client/connection.go @@ -9,6 +9,14 @@ * Beschreibung: * Aufbau einer Verbindung zwischen einem lokalen virtuellen seriellen Port * und dem zugehörigen entfernten Gerät. + * + * Die konfigurierte lokale virtuelle Schnittstelle wird dabei exakt + * übernommen. Eine Konfiguration wie + * + * /dev/ttyUSB100 -> radio + * + * führt somit technisch auch zur Erzeugung beziehungsweise Verwendung von + * /dev/ttyUSB100 und nicht einfach zum nächsten freien Port. * ============================================================================ */ package client @@ -16,19 +24,65 @@ package client import ( "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 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", + 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 /////////////////////////////////////////////////////////////////////////////// -// connectVirtualPort opens the local virtual port and the corresponding -// remote TCP data connection and returns both endpoints. +// connectVirtualPort opens the configured local virtual port and the +// corresponding remote TCP data connection and returns both endpoints. // // The caller owns the returned virtual port and TCP connection. +// +// The local virtual port is opened using exactly the port number specified +// in virtualPortConfig.Port. This preserves the user's configured mapping +// between local virtual interfaces and remote devices. func connectVirtualPort( c *Client, manager *VirtualPortManager, @@ -48,10 +102,16 @@ func connectVirtualPort( return nil, nil, err } - virtualPort, err := manager.Open() + portNumber, err := virtualPortNumber(virtualPortConfig.Port) + if err != nil { + return nil, nil, err + } + + virtualPort, err := manager.OpenSpecific(portNumber) if err != nil { return nil, nil, fmt.Errorf( - "open virtual port for %q: %w", + "open virtual port %q for %q: %w", + virtualPortConfig.Port, virtualPortConfig.RemoteDevice, err, ) diff --git a/internal/client/connection_test.go b/internal/client/connection_test.go index 46d0d65..74f9192 100644 --- a/internal/client/connection_test.go +++ b/internal/client/connection_test.go @@ -367,6 +367,83 @@ func TestConnectVirtualPortSuccess(t *testing.T) { } } +func TestConnectVirtualPortUsesConfiguredPort(t *testing.T) { + dataAddress, accepted := startTestDataServer(t) + + device := remoteDeviceFromAddress( + t, + "radio", + dataAddress, + ) + + client := newTestClient(t) + + manager := NewVirtualPortManager( + config.VirtualPortRangeConfig{ + First: 100, + Last: 199, + }, + ) + + cfg := config.VirtualPortConfig{ + Port: "/dev/ttyUSB150", + RemoteDevice: "radio", + } + + virtualPort, dataConn, err := connectVirtualPort( + client, + manager, + cfg, + []transport.RemoteDeviceInfo{device}, + ) + if err != nil { + t.Fatalf("connectVirtualPort: %v", err) + } + + if virtualPort == nil { + t.Fatal("virtual port is nil") + } + + if dataConn == nil { + t.Fatal("data connection is nil") + } + + defer virtualPort.Close() + defer dataConn.Close() + + if virtualPort.Path() != "/dev/ttyUSB150" { + t.Fatalf( + "virtual port path = %q, want %q", + virtualPort.Path(), + "/dev/ttyUSB150", + ) + } + + // Port 100 must still be available. This proves that the connection + // did not simply take the first free port from the configured range. + number, err := manager.Reserve() + if err != nil { + t.Fatalf("Reserve() after connection: %v", err) + } + + if number != 100 { + t.Fatalf( + "first free port after connection = %d, want 100", + number, + ) + } + + manager.Release(number) + + select { + case conn := <-accepted: + _ = conn.Close() + + case <-t.Context().Done(): + t.Fatal("test context cancelled while waiting for data connection") + } +} + func TestConnectBridgeSuccess(t *testing.T) { dataAddress, accepted := startTestDataServer(t) diff --git a/internal/client/virtual_port_manager.go b/internal/client/virtual_port_manager.go index 8998617..a365f90 100644 --- a/internal/client/virtual_port_manager.go +++ b/internal/client/virtual_port_manager.go @@ -9,6 +9,19 @@ * Beschreibung: * Verwaltung und Auswahl der für rs2322tcp reservierten virtuellen * seriellen Ports. + * + * Der VirtualPortManager verwaltet den für rs2322tcp vorgesehenen Bereich + * von /dev/ttyUSBxxx-Schnittstellen. + * + * 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 @@ -64,6 +77,9 @@ func (m *VirtualPortManager) PortPath(number int) string { // // Es wird immer mit dem kleinsten freien Port im konfigurierten Bereich // begonnen. +// +// Diese Methode bleibt für Fälle erhalten, in denen keine bestimmte +// Portnummer vorgegeben ist. func (m *VirtualPortManager) Reserve() (int, error) { if m == nil { return 0, fmt.Errorf("virtual port manager is nil") @@ -89,6 +105,42 @@ func (m *VirtualPortManager) Reserve() (int, error) { ) } +// ReserveSpecific reserviert exakt den angegebenen virtuellen Port. +// +// Der Port muss innerhalb des konfigurierten Bereichs liegen und darf +// momentan nicht bereits reserviert sein. +// +// Diese Methode wird für konfigurierte Zuordnungen verwendet, bei denen +// die Portnummer aus client.json verbindlich ist. +func (m *VirtualPortManager) ReserveSpecific(number int) error { + if m == nil { + return fmt.Errorf("virtual port manager is nil") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if number < m.first || number > m.last { + return fmt.Errorf( + "virtual port %d is outside configured range %d-%d", + number, + m.first, + m.last, + ) + } + + if m.used[number] { + return fmt.Errorf( + "virtual port %d is already reserved", + number, + ) + } + + m.used[number] = true + + return nil +} + // Release gibt einen zuvor reservierten Port wieder frei. func (m *VirtualPortManager) Release(number int) { if m == nil { diff --git a/internal/client/virtual_port_manager_linux.go b/internal/client/virtual_port_manager_linux.go index 34812f0..d190ca1 100644 --- a/internal/client/virtual_port_manager_linux.go +++ b/internal/client/virtual_port_manager_linux.go @@ -14,6 +14,9 @@ * Der öffentliche /dev/ttyUSBxxx-Eintrag wird bei der Installation * bereitgestellt. Der Client verwaltet ausschließlich den internen Link * unter ~/.rs2322tcp/virtual/. + * + * Virtuelle Ports können entweder automatisch über den nächsten freien Port + * oder gezielt über eine vorgegebene Portnummer geöffnet werden. * ============================================================================ */ package client @@ -140,6 +143,9 @@ func (p *ManagedVirtualPort) Close() error { // // 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") @@ -150,6 +156,39 @@ func (m *VirtualPortManager) Open() (*ManagedVirtualPort, error) { 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 +// reserved port number. +// +// The caller must reserve the port before calling this method. +func (m *VirtualPortManager) openReserved(number int) (*ManagedVirtualPort, error) { portPath := m.PortPath(number) serial, err := newVirtualSerial() diff --git a/internal/client/virtual_port_manager_linux_test.go b/internal/client/virtual_port_manager_linux_test.go index ad02ca3..810ea9a 100644 --- a/internal/client/virtual_port_manager_linux_test.go +++ b/internal/client/virtual_port_manager_linux_test.go @@ -79,6 +79,59 @@ func TestVirtualPortManagerOpen(t *testing.T) { } } +func TestVirtualPortManagerOpenSpecific(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + manager := NewVirtualPortManager( + config.VirtualPortRangeConfig{ + First: 100, + Last: 199, + }, + ) + + port, err := manager.OpenSpecific(150) + if err != nil { + t.Fatalf("OpenSpecific(150) failed: %v", err) + } + + if port == nil { + t.Fatal("OpenSpecific(150) returned nil") + } + + if port.Path() != "/dev/ttyUSB150" { + t.Errorf( + "Path() = %q, want %q", + port.Path(), + "/dev/ttyUSB150", + ) + } + + linkPath, err := virtualPortLinkPath(port.Path()) + if err != nil { + t.Fatalf("virtualPortLinkPath() failed: %v", err) + } + + target, err := os.Readlink(linkPath) + if err != nil { + t.Fatalf("Readlink() failed: %v", err) + } + + if target == "" { + t.Fatal("internal virtual port link has empty target") + } + + if err := port.Close(); err != nil { + t.Fatalf("Close() failed: %v", err) + } + + 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) diff --git a/internal/client/virtual_port_manager_test.go b/internal/client/virtual_port_manager_test.go index 9a2da9c..7d59fc3 100644 --- a/internal/client/virtual_port_manager_test.go +++ b/internal/client/virtual_port_manager_test.go @@ -8,6 +8,10 @@ * * Beschreibung: * Tests für die Auswahl und Verwaltung virtueller serieller Ports. + * + * Die Tests decken sowohl die automatische Reservierung über Reserve() + * als auch die gezielte Reservierung eines bestimmten Ports über + * ReserveSpecific() ab. * ============================================================================ */ package client @@ -201,6 +205,84 @@ func TestVirtualPortManagerPortPath(t *testing.T) { } } +func TestVirtualPortManagerReserveSpecific(t *testing.T) { + manager := NewVirtualPortManager(config.VirtualPortRangeConfig{ + First: 100, + Last: 199, + }) + + if err := manager.ReserveSpecific(150); err != nil { + t.Fatalf("ReserveSpecific(150) failed: %v", err) + } + + if !manager.used[150] { + t.Fatal("ReserveSpecific(150) did not reserve port 150") + } +} + +func TestVirtualPortManagerReserveSpecificOutsideRange(t *testing.T) { + manager := NewVirtualPortManager(config.VirtualPortRangeConfig{ + First: 100, + Last: 199, + }) + + tests := []int{ + 99, + 200, + } + + for _, number := range tests { + if err := manager.ReserveSpecific(number); err == nil { + t.Errorf( + "ReserveSpecific(%d) succeeded, want range error", + number, + ) + } + + if manager.used[number] { + t.Errorf( + "ReserveSpecific(%d) marked out-of-range port as used", + number, + ) + } + } +} + +func TestVirtualPortManagerReserveSpecificAlreadyReserved(t *testing.T) { + manager := NewVirtualPortManager(config.VirtualPortRangeConfig{ + First: 100, + Last: 199, + }) + + if err := manager.ReserveSpecific(100); err != nil { + t.Fatalf("first ReserveSpecific(100) failed: %v", err) + } + + if err := manager.ReserveSpecific(100); err == nil { + t.Fatal("second ReserveSpecific(100) succeeded, want already-reserved error") + } +} + +func TestVirtualPortManagerReserveSpecificAfterRelease(t *testing.T) { + manager := NewVirtualPortManager(config.VirtualPortRangeConfig{ + First: 100, + Last: 199, + }) + + if err := manager.ReserveSpecific(150); err != nil { + t.Fatalf("first ReserveSpecific(150) failed: %v", err) + } + + manager.Release(150) + + if err := manager.ReserveSpecific(150); err != nil { + t.Fatalf( + "ReserveSpecific(150) after Release() failed: %v", + err, + ) + } +} + func TestVirtualPortManagerNil(t *testing.T) { var manager *VirtualPortManager @@ -212,5 +294,9 @@ func TestVirtualPortManagerNil(t *testing.T) { t.Fatal("nil Reserve() succeeded, want error") } + if err := manager.ReserveSpecific(100); err == nil { + t.Fatal("nil ReserveSpecific() succeeded, want error") + } + manager.Release(100) }