Respect configured virtual port assignments

This commit is contained in:
Dieter Lang 2026-08-11 11:29:49 +02:00
parent f3610b989c
commit 80e4196e16
6 changed files with 371 additions and 4 deletions

View file

@ -9,6 +9,14 @@
* Beschreibung: * Beschreibung:
* Aufbau einer Verbindung zwischen einem lokalen virtuellen seriellen Port * Aufbau einer Verbindung zwischen einem lokalen virtuellen seriellen Port
* und dem zugehörigen entfernten Gerät. * 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 package client
@ -16,19 +24,65 @@ package client
import ( import (
"fmt" "fmt"
"net" "net"
"strconv"
"strings"
"git.lang-dieter.de/rs2322tcp/internal/config" "git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/transport" "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 // Virtual port connection
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
// connectVirtualPort opens the local virtual port and the corresponding // connectVirtualPort opens the configured local virtual port and the
// remote TCP data connection and returns both endpoints. // corresponding remote TCP data connection and returns both endpoints.
// //
// The caller owns the returned virtual port and TCP connection. // 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( func connectVirtualPort(
c *Client, c *Client,
manager *VirtualPortManager, manager *VirtualPortManager,
@ -48,10 +102,16 @@ func connectVirtualPort(
return nil, nil, err 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 { if err != nil {
return nil, nil, fmt.Errorf( return nil, nil, fmt.Errorf(
"open virtual port for %q: %w", "open virtual port %q for %q: %w",
virtualPortConfig.Port,
virtualPortConfig.RemoteDevice, virtualPortConfig.RemoteDevice,
err, err,
) )

View file

@ -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) { func TestConnectBridgeSuccess(t *testing.T) {
dataAddress, accepted := startTestDataServer(t) dataAddress, accepted := startTestDataServer(t)

View file

@ -9,6 +9,19 @@
* Beschreibung: * Beschreibung:
* Verwaltung und Auswahl der für rs2322tcp reservierten virtuellen * Verwaltung und Auswahl der für rs2322tcp reservierten virtuellen
* seriellen Ports. * 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 package client
@ -64,6 +77,9 @@ func (m *VirtualPortManager) PortPath(number int) string {
// //
// Es wird immer mit dem kleinsten freien Port im konfigurierten Bereich // Es wird immer mit dem kleinsten freien Port im konfigurierten Bereich
// begonnen. // begonnen.
//
// Diese Methode bleibt für Fälle erhalten, in denen keine bestimmte
// Portnummer vorgegeben ist.
func (m *VirtualPortManager) Reserve() (int, error) { func (m *VirtualPortManager) Reserve() (int, error) {
if m == nil { if m == nil {
return 0, fmt.Errorf("virtual port manager is 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. // Release gibt einen zuvor reservierten Port wieder frei.
func (m *VirtualPortManager) Release(number int) { func (m *VirtualPortManager) Release(number int) {
if m == nil { if m == nil {

View file

@ -14,6 +14,9 @@
* Der öffentliche /dev/ttyUSBxxx-Eintrag wird bei der Installation * Der öffentliche /dev/ttyUSBxxx-Eintrag wird bei der Installation
* bereitgestellt. Der Client verwaltet ausschließlich den internen Link * bereitgestellt. Der Client verwaltet ausschließlich den internen Link
* unter ~/.rs2322tcp/virtual/. * 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 package client
@ -140,6 +143,9 @@ func (p *ManagedVirtualPort) Close() error {
// //
// The internal PTY is created by newVirtualSerial(). The internal // The internal PTY is created by newVirtualSerial(). The internal
// ~/.rs2322tcp/virtual/ttyUSBxxx link is then pointed at the PTY. // ~/.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) { func (m *VirtualPortManager) Open() (*ManagedVirtualPort, error) {
if m == nil { if m == nil {
return nil, fmt.Errorf("virtual port manager is nil") return nil, fmt.Errorf("virtual port manager is nil")
@ -150,6 +156,39 @@ func (m *VirtualPortManager) Open() (*ManagedVirtualPort, error) {
return nil, err 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) portPath := m.PortPath(number)
serial, err := newVirtualSerial() serial, err := newVirtualSerial()

View file

@ -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) { func TestVirtualPortManagerOpenUsesNextPort(t *testing.T) {
tempHome := t.TempDir() tempHome := t.TempDir()
t.Setenv("HOME", tempHome) t.Setenv("HOME", tempHome)

View file

@ -8,6 +8,10 @@
* *
* Beschreibung: * Beschreibung:
* Tests für die Auswahl und Verwaltung virtueller serieller Ports. * 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 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) { func TestVirtualPortManagerNil(t *testing.T) {
var manager *VirtualPortManager var manager *VirtualPortManager
@ -212,5 +294,9 @@ func TestVirtualPortManagerNil(t *testing.T) {
t.Fatal("nil Reserve() succeeded, want error") t.Fatal("nil Reserve() succeeded, want error")
} }
if err := manager.ReserveSpecific(100); err == nil {
t.Fatal("nil ReserveSpecific() succeeded, want error")
}
manager.Release(100) manager.Release(100)
} }