Add client runtime and serial bridge
This commit is contained in:
parent
1d3239da71
commit
9d5425975b
14 changed files with 2003 additions and 44 deletions
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -2,6 +2,26 @@
|
||||||
|
|
||||||
Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert.
|
Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert.
|
||||||
|
|
||||||
|
## [0.0.6] - 2026-08-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Clientseitige Runtime zur Verwaltung mehrerer virtueller serieller Ports
|
||||||
|
- Verbindung konfigurierter virtueller Ports mit den vom Server bereitgestellten
|
||||||
|
Remote-Geräten
|
||||||
|
- Clientseitige TCP-Data-Connections zu den dynamischen Data-Ports des Servers
|
||||||
|
- Bidirektionale Datenübertragung über eine `Bridge`
|
||||||
|
- Verwaltung des Lebenszyklus von virtuellen Ports, Bridges und Client-Verbindung
|
||||||
|
- Integrationstest für den vollständigen Datenweg zwischen virtuellem seriellen
|
||||||
|
Port und Server
|
||||||
|
- Tests für Verbindungsaufbau, Gerätezuordnung und Runtime-Lebenszyklus
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `go test ./...` erfolgreich
|
||||||
|
- `go test -race ./...` erfolgreich
|
||||||
|
- Bidirektionale Übertragung über Runtime, Bridge, TCP und PTY erfolgreich
|
||||||
|
- Race Conditions in Client-Control-Verbindung, PTY und virtuellen Ports behoben
|
||||||
## [0.0.5] - 2026-08-10
|
## [0.0.5] - 2026-08-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
130
internal/client/bridge.go
Normal file
130
internal/client/bridge.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
/*
|
||||||
|
* ============================================================================
|
||||||
|
* Projekt.....: rs2322tcp
|
||||||
|
* Datei.......: internal/client/bridge.go
|
||||||
|
* Copyright (C) 2026 Dieter Lang
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Beschreibung:
|
||||||
|
* Bidirektionale Daten-Bridge zwischen der virtuellen seriellen
|
||||||
|
* Schnittstelle des Clients und der TCP-Data-Verbindung.
|
||||||
|
*
|
||||||
|
* Die Bridge transportiert den Byte-Strom in beide Richtungen:
|
||||||
|
*
|
||||||
|
* VirtualSerial ─────► TCP
|
||||||
|
* VirtualSerial ◄───── TCP
|
||||||
|
*
|
||||||
|
* Die Bridge kennt weder das Control-Protokoll noch die konkrete
|
||||||
|
* serielle Hardware. Sie verbindet ausschließlich zwei io.ReadWriter-
|
||||||
|
* Endpunkte miteinander.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Bridge
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Bridge verbindet eine virtuelle serielle Schnittstelle mit einer
|
||||||
|
// TCP-Data-Verbindung.
|
||||||
|
//
|
||||||
|
// Beide Datenrichtungen werden gleichzeitig bedient. Dadurch bleibt der
|
||||||
|
// serielle Datenstrom vollständig bidirektional.
|
||||||
|
type Bridge struct {
|
||||||
|
serial VirtualSerial
|
||||||
|
conn net.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Constructor
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// NewBridge creates a new bidirectional data bridge.
|
||||||
|
//
|
||||||
|
// The bridge does not take ownership of the endpoints until Run is called.
|
||||||
|
func NewBridge(serial VirtualSerial, conn net.Conn) (*Bridge, error) {
|
||||||
|
if serial == nil {
|
||||||
|
return nil, fmt.Errorf("virtual serial is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if conn == nil {
|
||||||
|
return nil, fmt.Errorf("TCP connection is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Bridge{
|
||||||
|
serial: serial,
|
||||||
|
conn: conn,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Run
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Run starts the bidirectional byte transfer and blocks until one of the
|
||||||
|
// directions terminates.
|
||||||
|
//
|
||||||
|
// Closing the endpoints after termination releases the opposite blocked
|
||||||
|
// transfer as well.
|
||||||
|
func (b *Bridge) Run() error {
|
||||||
|
if b == nil || b.serial == nil {
|
||||||
|
return fmt.Errorf("bridge is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.conn == nil {
|
||||||
|
return fmt.Errorf("TCP connection is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make(chan result, 2)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
_, err := io.Copy(b.conn, b.serial)
|
||||||
|
results <- result{
|
||||||
|
name: "serial to TCP",
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
_, err := io.Copy(b.serial, b.conn)
|
||||||
|
results <- result{
|
||||||
|
name: "TCP to serial",
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
first := <-results
|
||||||
|
|
||||||
|
// Beide Richtungen sollen beendet werden. Das Schließen der Endpunkte
|
||||||
|
// unterbricht insbesondere einen eventuell noch blockierenden Read.
|
||||||
|
_ = b.conn.Close()
|
||||||
|
_ = b.serial.Close()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if first.err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", first.name, first.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
271
internal/client/bridge_test.go
Normal file
271
internal/client/bridge_test.go
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
/*
|
||||||
|
* ============================================================================
|
||||||
|
* Projekt.....: rs2322tcp
|
||||||
|
* Datei.......: internal/client/bridge_test.go
|
||||||
|
* Copyright (C) 2026 Dieter Lang
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Beschreibung:
|
||||||
|
* Tests für die bidirektionale Daten-Bridge zwischen VirtualSerial und
|
||||||
|
* TCP-Verbindung.
|
||||||
|
*
|
||||||
|
* Die Tests verwenden net.Pipe und eine kleine In-Memory-Implementierung
|
||||||
|
* von VirtualSerial. Dadurch wird ausschließlich die Daten-Bridge getestet,
|
||||||
|
* ohne echte TCP-Ports oder eine reale PTY-Schnittstelle zu benötigen.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Test VirtualSerial
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
type testVirtualSerial struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
reader *bytes.Reader
|
||||||
|
writes [][]byte
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestVirtualSerial(data []byte) *testVirtualSerial {
|
||||||
|
return &testVirtualSerial{
|
||||||
|
reader: bytes.NewReader(data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testVirtualSerial) Path() string {
|
||||||
|
return "test-serial"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testVirtualSerial) Read(p []byte) (int, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.closed {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.reader.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testVirtualSerial) Write(p []byte) (int, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.closed {
|
||||||
|
return 0, io.ErrClosedPipe
|
||||||
|
}
|
||||||
|
|
||||||
|
cp := append([]byte(nil), p...)
|
||||||
|
s.writes = append(s.writes, cp)
|
||||||
|
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testVirtualSerial) Written() []byte {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
var result []byte
|
||||||
|
for _, p := range s.writes {
|
||||||
|
result = append(result, p...)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testVirtualSerial) Close() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Blocking test VirtualSerial
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
type blockingVirtualSerial struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
writes [][]byte
|
||||||
|
readCh chan struct{}
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBlockingVirtualSerial() *blockingVirtualSerial {
|
||||||
|
return &blockingVirtualSerial{
|
||||||
|
readCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *blockingVirtualSerial) Path() string {
|
||||||
|
return "blocking-test-serial"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *blockingVirtualSerial) Read([]byte) (int, error) {
|
||||||
|
<-s.readCh
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *blockingVirtualSerial) Write(p []byte) (int, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.closed {
|
||||||
|
return 0, io.ErrClosedPipe
|
||||||
|
}
|
||||||
|
|
||||||
|
s.writes = append(s.writes, append([]byte(nil), p...))
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *blockingVirtualSerial) Written() []byte {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
var result []byte
|
||||||
|
for _, p := range s.writes {
|
||||||
|
result = append(result, p...)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *blockingVirtualSerial) Close() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if !s.closed {
|
||||||
|
s.closed = true
|
||||||
|
close(s.readCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Tests
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func TestNewBridge(t *testing.T) {
|
||||||
|
serial := newTestVirtualSerial(nil)
|
||||||
|
connA, connB := net.Pipe()
|
||||||
|
defer connA.Close()
|
||||||
|
defer connB.Close()
|
||||||
|
|
||||||
|
if _, err := NewBridge(nil, connA); err == nil {
|
||||||
|
t.Fatal("expected error for nil VirtualSerial")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := NewBridge(serial, nil); err == nil {
|
||||||
|
t.Fatal("expected error for nil TCP connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
bridge, err := NewBridge(serial, connA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBridge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bridge.serial != serial {
|
||||||
|
t.Fatal("bridge serial endpoint mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if bridge.conn != connA {
|
||||||
|
t.Fatal("bridge TCP endpoint mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBridgeSerialToTCP(t *testing.T) {
|
||||||
|
serial := newTestVirtualSerial([]byte("hello"))
|
||||||
|
|
||||||
|
clientConn, bridgeConn := net.Pipe()
|
||||||
|
|
||||||
|
bridge, err := NewBridge(serial, bridgeConn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBridge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
runDone <- bridge.Run()
|
||||||
|
}()
|
||||||
|
|
||||||
|
got := make([]byte, 5)
|
||||||
|
if _, err := io.ReadFull(clientConn, got); err != nil {
|
||||||
|
t.Fatalf("read TCP data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(got) != "hello" {
|
||||||
|
t.Fatalf("received %q, want %q", got, "hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = clientConn.Close()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-runDone:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Bridge.Run: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Bridge.Run did not terminate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBridgeTCPToSerial(t *testing.T) {
|
||||||
|
serial := newBlockingVirtualSerial()
|
||||||
|
|
||||||
|
clientConn, bridgeConn := net.Pipe()
|
||||||
|
|
||||||
|
bridge, err := NewBridge(serial, bridgeConn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBridge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
runDone <- bridge.Run()
|
||||||
|
}()
|
||||||
|
|
||||||
|
want := []byte{0x46, 0x41, 0x00, 0x10, 0x0D}
|
||||||
|
|
||||||
|
if _, err := clientConn.Write(want); err != nil {
|
||||||
|
t.Fatalf("write TCP data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
if bytes.Equal(serial.Written(), want) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf(
|
||||||
|
"serial received % X, want % X",
|
||||||
|
serial.Written(),
|
||||||
|
want,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = clientConn.Close()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-runDone:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Bridge.Run: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Bridge.Run did not terminate")
|
||||||
|
}
|
||||||
|
}
|
||||||
111
internal/client/connection.go
Normal file
111
internal/client/connection.go
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/*
|
||||||
|
* ============================================================================
|
||||||
|
* Projekt.....: rs2322tcp
|
||||||
|
* Datei.......: internal/client/connection.go
|
||||||
|
* Copyright (C) 2026 Dieter Lang
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Beschreibung:
|
||||||
|
* Aufbau einer Verbindung zwischen einem lokalen virtuellen seriellen Port
|
||||||
|
* und dem zugehörigen entfernten Gerät.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Virtual port connection
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// connectVirtualPort opens the local virtual port and the corresponding
|
||||||
|
// remote TCP data connection and returns both endpoints.
|
||||||
|
//
|
||||||
|
// The caller owns the returned virtual port and TCP connection.
|
||||||
|
func connectVirtualPort(
|
||||||
|
c *Client,
|
||||||
|
manager *VirtualPortManager,
|
||||||
|
virtualPortConfig config.VirtualPortConfig,
|
||||||
|
devices []transport.RemoteDeviceInfo,
|
||||||
|
) (*ManagedVirtualPort, net.Conn, error) {
|
||||||
|
if c == nil || c.conn == nil {
|
||||||
|
return nil, nil, fmt.Errorf("client is not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
if manager == nil {
|
||||||
|
return nil, nil, fmt.Errorf("virtual port manager is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
device, err := findRemoteDevice(virtualPortConfig, devices)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
virtualPort, err := manager.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf(
|
||||||
|
"open virtual port for %q: %w",
|
||||||
|
virtualPortConfig.RemoteDevice,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := c.OpenDataConnection(device)
|
||||||
|
if err != nil {
|
||||||
|
_ = virtualPort.Close()
|
||||||
|
|
||||||
|
return nil, nil, fmt.Errorf(
|
||||||
|
"open data connection for %q: %w",
|
||||||
|
device.ID,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return virtualPort, conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Bridge connection
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// connectBridge creates the local virtual port, opens the corresponding
|
||||||
|
// remote TCP data connection and creates the data bridge.
|
||||||
|
//
|
||||||
|
// The caller owns the returned bridge and its endpoints.
|
||||||
|
func connectBridge(
|
||||||
|
c *Client,
|
||||||
|
manager *VirtualPortManager,
|
||||||
|
virtualPortConfig config.VirtualPortConfig,
|
||||||
|
devices []transport.RemoteDeviceInfo,
|
||||||
|
) (*Bridge, *ManagedVirtualPort, net.Conn, error) {
|
||||||
|
virtualPort, conn, err := connectVirtualPort(
|
||||||
|
c,
|
||||||
|
manager,
|
||||||
|
virtualPortConfig,
|
||||||
|
devices,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bridge, err := NewBridge(virtualPort, conn)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
_ = virtualPort.Close()
|
||||||
|
|
||||||
|
return nil, nil, nil, fmt.Errorf(
|
||||||
|
"create bridge for %q: %w",
|
||||||
|
virtualPortConfig.RemoteDevice,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bridge, virtualPort, conn, nil
|
||||||
|
}
|
||||||
443
internal/client/connection_test.go
Normal file
443
internal/client/connection_test.go
Normal file
|
|
@ -0,0 +1,443 @@
|
||||||
|
/*
|
||||||
|
* ============================================================================
|
||||||
|
* Projekt.....: rs2322tcp
|
||||||
|
* Datei.......: internal/client/connection_test.go
|
||||||
|
* Copyright (C) 2026 Dieter Lang
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Beschreibung:
|
||||||
|
* Tests für den Aufbau einer Verbindung zwischen einem lokalen virtuellen
|
||||||
|
* seriellen Port und dem zugehörigen entfernten TCP-Gerät.
|
||||||
|
*
|
||||||
|
* Die Tests prüfen insbesondere die Auswahl des Remote-Geräts, die Erzeugung
|
||||||
|
* des virtuellen Ports, den Aufbau der TCP-Datenverbindung und die Verbindung
|
||||||
|
* dieser Endpunkte über eine Bridge.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Test connection
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// testRemoteAddrConn wraps a net.Conn and provides a TCP-style remote address.
|
||||||
|
//
|
||||||
|
// net.Pipe() is used for the control connection in these tests. Its default
|
||||||
|
// RemoteAddr() is "pipe", which cannot be used by OpenDataConnection() to
|
||||||
|
// determine the server host. This wrapper supplies a normal TCP address.
|
||||||
|
type testRemoteAddrConn struct {
|
||||||
|
net.Conn
|
||||||
|
remoteAddr net.Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteAddr returns the TCP-style remote address configured for the test.
|
||||||
|
func (c *testRemoteAddrConn) RemoteAddr() net.Addr {
|
||||||
|
return c.remoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Test helpers
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// startTestDataServer starts a local TCP listener for a simulated remote
|
||||||
|
// device.
|
||||||
|
//
|
||||||
|
// The listener accepts exactly one connection. The accepted connection is
|
||||||
|
// returned through the channel so the test can verify that the client has
|
||||||
|
// successfully established the data connection.
|
||||||
|
func startTestDataServer(t *testing.T) (string, <-chan net.Conn) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen for test data server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = listener.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
accepted := make(chan net.Conn, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted <- conn
|
||||||
|
}()
|
||||||
|
|
||||||
|
return listener.Addr().String(), accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
// remoteDeviceFromAddress creates a RemoteDeviceInfo whose DataPort is
|
||||||
|
// taken from the supplied local TCP address.
|
||||||
|
func remoteDeviceFromAddress(
|
||||||
|
t *testing.T,
|
||||||
|
id string,
|
||||||
|
address string,
|
||||||
|
) transport.RemoteDeviceInfo {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, portText, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("split test server address %q: %v", address, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := strconv.Atoi(portText)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse test server port %q: %v", portText, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return transport.RemoteDeviceInfo{
|
||||||
|
ID: id,
|
||||||
|
Name: id,
|
||||||
|
DataPort: port,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestClient creates a Client whose control connection uses net.Pipe()
|
||||||
|
// but reports a normal TCP-style remote address.
|
||||||
|
func newTestClient(t *testing.T) *Client {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
controlClient, serverConn := net.Pipe()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = controlClient.Close()
|
||||||
|
_ = serverConn.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
conn: &testRemoteAddrConn{
|
||||||
|
Conn: controlClient,
|
||||||
|
remoteAddr: &net.TCPAddr{
|
||||||
|
IP: net.ParseIP("127.0.0.1"),
|
||||||
|
Port: 5000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Tests
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func TestConnectVirtualPortNilClient(t *testing.T) {
|
||||||
|
manager := NewVirtualPortManager(
|
||||||
|
config.VirtualPortRangeConfig{
|
||||||
|
First: 100,
|
||||||
|
Last: 199,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg := config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB100",
|
||||||
|
RemoteDevice: "radio",
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := []transport.RemoteDeviceInfo{
|
||||||
|
{
|
||||||
|
ID: "radio",
|
||||||
|
DataPort: 50123,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err := connectVirtualPort(
|
||||||
|
nil,
|
||||||
|
manager,
|
||||||
|
cfg,
|
||||||
|
devices,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil client")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "client is not connected") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectVirtualPortNilManager(t *testing.T) {
|
||||||
|
cfg := config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB100",
|
||||||
|
RemoteDevice: "radio",
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := []transport.RemoteDeviceInfo{
|
||||||
|
{
|
||||||
|
ID: "radio",
|
||||||
|
DataPort: 50123,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err := connectVirtualPort(
|
||||||
|
&Client{},
|
||||||
|
nil,
|
||||||
|
cfg,
|
||||||
|
devices,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil manager")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "client is not connected") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectVirtualPortUnknownDevice(t *testing.T) {
|
||||||
|
manager := NewVirtualPortManager(
|
||||||
|
config.VirtualPortRangeConfig{
|
||||||
|
First: 100,
|
||||||
|
Last: 199,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client := newTestClient(t)
|
||||||
|
|
||||||
|
cfg := config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB100",
|
||||||
|
RemoteDevice: "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := []transport.RemoteDeviceInfo{
|
||||||
|
{
|
||||||
|
ID: "radio",
|
||||||
|
DataPort: 50123,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err := connectVirtualPort(
|
||||||
|
client,
|
||||||
|
manager,
|
||||||
|
cfg,
|
||||||
|
devices,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown device")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(
|
||||||
|
err.Error(),
|
||||||
|
`remote device "unknown" not found`,
|
||||||
|
) {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectVirtualPortClosesPortWhenDataConnectionFails(t *testing.T) {
|
||||||
|
manager := NewVirtualPortManager(
|
||||||
|
config.VirtualPortRangeConfig{
|
||||||
|
First: 100,
|
||||||
|
Last: 100,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client := newTestClient(t)
|
||||||
|
|
||||||
|
cfg := config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB100",
|
||||||
|
RemoteDevice: "radio",
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := []transport.RemoteDeviceInfo{
|
||||||
|
{
|
||||||
|
ID: "radio",
|
||||||
|
DataPort: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
port, conn, err := connectVirtualPort(
|
||||||
|
client,
|
||||||
|
manager,
|
||||||
|
cfg,
|
||||||
|
devices,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
if conn != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if port != nil {
|
||||||
|
_ = port.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatal("expected data connection error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if port != nil {
|
||||||
|
t.Fatal(
|
||||||
|
"virtual port must not be returned after connection failure",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if conn != nil {
|
||||||
|
t.Fatal(
|
||||||
|
"TCP connection must not be returned after connection failure",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port 100 must have been released after the failed connection.
|
||||||
|
portNumber, err := manager.Reserve()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Reserve after cleanup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if portNumber != 100 {
|
||||||
|
t.Fatalf("reserved port = %d, want 100", portNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.Release(portNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectVirtualPortSuccess(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/ttyUSB100",
|
||||||
|
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/ttyUSB100" {
|
||||||
|
t.Fatalf(
|
||||||
|
"virtual port path = %q, want %q",
|
||||||
|
virtualPort.Path(),
|
||||||
|
"/dev/ttyUSB100",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The test data server must receive the connection created by
|
||||||
|
// OpenDataConnection().
|
||||||
|
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)
|
||||||
|
|
||||||
|
device := remoteDeviceFromAddress(
|
||||||
|
t,
|
||||||
|
"radio",
|
||||||
|
dataAddress,
|
||||||
|
)
|
||||||
|
|
||||||
|
client := newTestClient(t)
|
||||||
|
|
||||||
|
manager := NewVirtualPortManager(
|
||||||
|
config.VirtualPortRangeConfig{
|
||||||
|
First: 100,
|
||||||
|
Last: 199,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg := config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB100",
|
||||||
|
RemoteDevice: "radio",
|
||||||
|
}
|
||||||
|
|
||||||
|
bridge, virtualPort, dataConn, err := connectBridge(
|
||||||
|
client,
|
||||||
|
manager,
|
||||||
|
cfg,
|
||||||
|
[]transport.RemoteDeviceInfo{device},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connectBridge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bridge == nil {
|
||||||
|
t.Fatal("bridge is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 bridge.serial != virtualPort {
|
||||||
|
t.Fatal("bridge serial endpoint does not match virtual port")
|
||||||
|
}
|
||||||
|
|
||||||
|
if bridge.conn != dataConn {
|
||||||
|
t.Fatal("bridge TCP endpoint does not match data connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
if virtualPort.Path() != "/dev/ttyUSB100" {
|
||||||
|
t.Fatalf(
|
||||||
|
"virtual port path = %q, want %q",
|
||||||
|
virtualPort.Path(),
|
||||||
|
"/dev/ttyUSB100",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The test data server must receive the TCP data connection created by
|
||||||
|
// connectBridge().
|
||||||
|
select {
|
||||||
|
case conn := <-accepted:
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
case <-t.Context().Done():
|
||||||
|
t.Fatal("test context cancelled while waiting for data connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/*
|
/*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* Projekt.....: rs2322tcp
|
* Projekt.....: rs2322tcp
|
||||||
* Datei.......: control.go
|
* Datei.......: internal/client/control.go
|
||||||
* Copyright (C) 2026 Dieter Lang
|
* Copyright (C) 2026 Dieter Lang
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||||
)
|
)
|
||||||
|
|
@ -28,6 +29,8 @@ import (
|
||||||
|
|
||||||
// Client represents a connection to an rs2322tcp server.
|
// Client represents a connection to an rs2322tcp server.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
reader *bufio.Reader
|
reader *bufio.Reader
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +82,9 @@ func (c *Client) Conn() net.Conn {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
return c.conn
|
return c.conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,12 +93,21 @@ func (c *Client) Conn() net.Conn {
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
func (c *Client) hello() error {
|
func (c *Client) hello() error {
|
||||||
if c == nil || c.conn == nil {
|
if c == nil {
|
||||||
|
return fmt.Errorf("client is not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
reader := c.reader
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if conn == nil {
|
||||||
return fmt.Errorf("client is not connected")
|
return fmt.Errorf("client is not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := transport.WriteMessage(
|
if err := transport.WriteMessage(
|
||||||
c.conn,
|
conn,
|
||||||
transport.NewHello(),
|
transport.NewHello(),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("send hello: %w", err)
|
return fmt.Errorf("send hello: %w", err)
|
||||||
|
|
@ -101,7 +116,7 @@ func (c *Client) hello() error {
|
||||||
var response transport.HelloResponseMessage
|
var response transport.HelloResponseMessage
|
||||||
|
|
||||||
if err := transport.ReadMessage(
|
if err := transport.ReadMessage(
|
||||||
c.reader,
|
reader,
|
||||||
&response,
|
&response,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("read hello response: %w", err)
|
return fmt.Errorf("read hello response: %w", err)
|
||||||
|
|
@ -125,24 +140,39 @@ func (c *Client) hello() error {
|
||||||
//
|
//
|
||||||
// The returned list contains the session-specific dynamic TCP data ports.
|
// The returned list contains the session-specific dynamic TCP data ports.
|
||||||
func (c *Client) GetDevices() ([]transport.RemoteDeviceInfo, error) {
|
func (c *Client) GetDevices() ([]transport.RemoteDeviceInfo, error) {
|
||||||
if c == nil || c.conn == nil {
|
if c == nil {
|
||||||
|
return nil, fmt.Errorf("client is not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
reader := c.reader
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if conn == nil {
|
||||||
return nil, fmt.Errorf("client is not connected")
|
return nil, fmt.Errorf("client is not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := transport.WriteMessage(
|
if err := transport.WriteMessage(
|
||||||
c.conn,
|
conn,
|
||||||
transport.NewGetDevices(),
|
transport.NewGetDevices(),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("send get-devices request: %w", err)
|
return nil, fmt.Errorf(
|
||||||
|
"send get-devices request: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var response transport.DeviceListMessage
|
var response transport.DeviceListMessage
|
||||||
|
|
||||||
if err := transport.ReadMessage(
|
if err := transport.ReadMessage(
|
||||||
c.reader,
|
reader,
|
||||||
&response,
|
&response,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("read device list: %w", err)
|
return nil, fmt.Errorf(
|
||||||
|
"read device list: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.Type != transport.MessageDeviceList {
|
if response.Type != transport.MessageDeviceList {
|
||||||
|
|
@ -160,13 +190,25 @@ func (c *Client) GetDevices() ([]transport.RemoteDeviceInfo, error) {
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
// Close closes the client control connection.
|
// Close closes the client control connection.
|
||||||
|
//
|
||||||
|
// The connection is removed from the Client while holding the mutex.
|
||||||
|
// The actual network close is performed afterwards so that another caller
|
||||||
|
// cannot race with the state change.
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
if c == nil || c.conn == nil {
|
if c == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := c.conn.Close()
|
c.mu.Lock()
|
||||||
|
|
||||||
|
conn := c.conn
|
||||||
c.conn = nil
|
c.conn = nil
|
||||||
|
|
||||||
return err
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if conn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn.Close()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
70
internal/client/device.go
Normal file
70
internal/client/device.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/*
|
||||||
|
* ============================================================================
|
||||||
|
* Projekt.....: rs2322tcp
|
||||||
|
* Datei.......: internal/client/device.go
|
||||||
|
* Copyright (C) 2026 Dieter Lang
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Beschreibung:
|
||||||
|
* Zuordnung eines lokalen virtuellen Ports zu einem vom Server gemeldeten
|
||||||
|
* Remote-Gerät.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Remote device lookup
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// findRemoteDevice finds the server-provided device information matching
|
||||||
|
// the remote device ID configured for a local virtual port.
|
||||||
|
//
|
||||||
|
// The caller uses the returned RemoteDeviceInfo to open the session-specific
|
||||||
|
// TCP data connection.
|
||||||
|
func findRemoteDevice(
|
||||||
|
virtualPort config.VirtualPortConfig,
|
||||||
|
devices []transport.RemoteDeviceInfo,
|
||||||
|
) (transport.RemoteDeviceInfo, error) {
|
||||||
|
if virtualPort.RemoteDevice == "" {
|
||||||
|
return transport.RemoteDeviceInfo{}, fmt.Errorf(
|
||||||
|
"remote device is empty for virtual port %q",
|
||||||
|
virtualPort.Port,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var found *transport.RemoteDeviceInfo
|
||||||
|
|
||||||
|
for i := range devices {
|
||||||
|
device := &devices[i]
|
||||||
|
|
||||||
|
if device.ID != virtualPort.RemoteDevice {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if found != nil {
|
||||||
|
return transport.RemoteDeviceInfo{}, fmt.Errorf(
|
||||||
|
"remote device %q is ambiguous",
|
||||||
|
virtualPort.RemoteDevice,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
found = device
|
||||||
|
}
|
||||||
|
|
||||||
|
if found == nil {
|
||||||
|
return transport.RemoteDeviceInfo{}, fmt.Errorf(
|
||||||
|
"remote device %q not found",
|
||||||
|
virtualPort.RemoteDevice,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return *found, nil
|
||||||
|
}
|
||||||
159
internal/client/device_test.go
Normal file
159
internal/client/device_test.go
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
/*
|
||||||
|
* ============================================================================
|
||||||
|
* Projekt.....: rs2322tcp
|
||||||
|
* Datei.......: internal/client/device_test.go
|
||||||
|
* Copyright (C) 2026 Dieter Lang
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Beschreibung:
|
||||||
|
* Tests für die Zuordnung lokaler virtueller Ports zu den vom Server
|
||||||
|
* gemeldeten Remote-Geräten.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Test data
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func testRemoteDevices() []transport.RemoteDeviceInfo {
|
||||||
|
return []transport.RemoteDeviceInfo{
|
||||||
|
{
|
||||||
|
ID: "radio",
|
||||||
|
Name: "Radio",
|
||||||
|
BaudRate: 9600,
|
||||||
|
DataBits: 8,
|
||||||
|
Parity: "none",
|
||||||
|
StopBits: 1,
|
||||||
|
DataPort: 50123,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rotor",
|
||||||
|
Name: "Rotor",
|
||||||
|
BaudRate: 4800,
|
||||||
|
DataBits: 8,
|
||||||
|
Parity: "none",
|
||||||
|
StopBits: 1,
|
||||||
|
DataPort: 50124,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Tests
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func TestFindRemoteDevice(t *testing.T) {
|
||||||
|
devices := testRemoteDevices()
|
||||||
|
|
||||||
|
virtualPort := config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB100",
|
||||||
|
RemoteDevice: "radio",
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := findRemoteDevice(virtualPort, devices)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findRemoteDevice: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.ID != "radio" {
|
||||||
|
t.Fatalf("ID = %q, want %q", got.ID, "radio")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.DataPort != 50123 {
|
||||||
|
t.Fatalf("DataPort = %d, want %d", got.DataPort, 50123)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindRemoteDeviceRotor(t *testing.T) {
|
||||||
|
devices := testRemoteDevices()
|
||||||
|
|
||||||
|
virtualPort := config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB101",
|
||||||
|
RemoteDevice: "rotor",
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := findRemoteDevice(virtualPort, devices)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findRemoteDevice: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.ID != "rotor" {
|
||||||
|
t.Fatalf("ID = %q, want %q", got.ID, "rotor")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.DataPort != 50124 {
|
||||||
|
t.Fatalf("DataPort = %d, want %d", got.DataPort, 50124)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindRemoteDeviceEmptyID(t *testing.T) {
|
||||||
|
devices := testRemoteDevices()
|
||||||
|
|
||||||
|
virtualPort := config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB100",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := findRemoteDevice(virtualPort, devices)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty remote device")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "remote device is empty") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindRemoteDeviceNotFound(t *testing.T) {
|
||||||
|
devices := testRemoteDevices()
|
||||||
|
|
||||||
|
virtualPort := config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB100",
|
||||||
|
RemoteDevice: "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := findRemoteDevice(virtualPort, devices)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown remote device")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), `remote device "unknown" not found`) {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindRemoteDeviceAmbiguous(t *testing.T) {
|
||||||
|
devices := []transport.RemoteDeviceInfo{
|
||||||
|
{
|
||||||
|
ID: "radio",
|
||||||
|
DataPort: 50123,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "radio",
|
||||||
|
DataPort: 50125,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
virtualPort := config.VirtualPortConfig{
|
||||||
|
Port: "/dev/ttyUSB100",
|
||||||
|
RemoteDevice: "radio",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := findRemoteDevice(virtualPort, devices)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for ambiguous remote device")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), `remote device "radio" is ambiguous`) {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,51 @@
|
||||||
//go:build linux
|
//go:build linux
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ============================================================================
|
||||||
|
* Projekt.....: rs2322tcp
|
||||||
|
* Datei.......: internal/client/pty_linux.go
|
||||||
|
* Copyright (C) 2026 Dieter Lang
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Beschreibung:
|
||||||
|
* Linux-spezifische Implementierung der virtuellen seriellen Schnittstelle
|
||||||
|
* über ein PTY-Paar.
|
||||||
|
*
|
||||||
|
* Der PTY-Master wird intern vom Client verwendet. Der zugehörige PTY-Slave
|
||||||
|
* wird über den virtuellen Port als serielle Schnittstelle bereitgestellt.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// ptySerial
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// ptySerial implements VirtualSerial using a Linux PTY pair.
|
||||||
|
//
|
||||||
|
// The file descriptors are stored atomically because Read/Write may execute
|
||||||
|
// concurrently with Close. Close must be able to invalidate the descriptors
|
||||||
|
// without racing with a concurrent Read or Write.
|
||||||
type ptySerial struct {
|
type ptySerial struct {
|
||||||
masterFD int
|
masterFD atomic.Int64
|
||||||
slaveFD int
|
slaveFD atomic.Int64
|
||||||
path string
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Constructor
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// newVirtualSerial creates a new Linux PTY pair.
|
||||||
func newVirtualSerial() (VirtualSerial, error) {
|
func newVirtualSerial() (VirtualSerial, error) {
|
||||||
masterFD, err := unix.Open(
|
masterFD, err := unix.Open(
|
||||||
"/dev/ptmx",
|
"/dev/ptmx",
|
||||||
|
|
@ -60,7 +92,11 @@ func newVirtualSerial() (VirtualSerial, error) {
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open PTY slave %s: %w", path, err)
|
return nil, fmt.Errorf(
|
||||||
|
"open PTY slave %s: %w",
|
||||||
|
path,
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
closeSlave := true
|
closeSlave := true
|
||||||
|
|
@ -74,16 +110,23 @@ func newVirtualSerial() (VirtualSerial, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
serial := &ptySerial{
|
||||||
|
path: path,
|
||||||
|
}
|
||||||
|
|
||||||
|
serial.masterFD.Store(int64(masterFD))
|
||||||
|
serial.slaveFD.Store(int64(slaveFD))
|
||||||
|
|
||||||
closeMaster = false
|
closeMaster = false
|
||||||
closeSlave = false
|
closeSlave = false
|
||||||
|
|
||||||
return &ptySerial{
|
return serial, nil
|
||||||
masterFD: masterFD,
|
|
||||||
slaveFD: slaveFD,
|
|
||||||
path: path,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// PTY configuration
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
func configurePTY(fd int) error {
|
func configurePTY(fd int) error {
|
||||||
termios, err := unix.IoctlGetTermios(
|
termios, err := unix.IoctlGetTermios(
|
||||||
fd,
|
fd,
|
||||||
|
|
@ -134,33 +177,57 @@ func configurePTY(fd int) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// VirtualSerial implementation
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Path returns the PTY slave path.
|
||||||
func (p *ptySerial) Path() string {
|
func (p *ptySerial) Path() string {
|
||||||
return p.path
|
return p.path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read reads raw bytes from the PTY master.
|
||||||
func (p *ptySerial) Read(b []byte) (int, error) {
|
func (p *ptySerial) Read(b []byte) (int, error) {
|
||||||
return unix.Read(p.masterFD, b)
|
fd := int(p.masterFD.Load())
|
||||||
|
|
||||||
|
if fd < 0 {
|
||||||
|
return 0, fmt.Errorf("PTY master is closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return unix.Read(fd, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write writes raw bytes to the PTY master.
|
||||||
func (p *ptySerial) Write(b []byte) (int, error) {
|
func (p *ptySerial) Write(b []byte) (int, error) {
|
||||||
return unix.Write(p.masterFD, b)
|
fd := int(p.masterFD.Load())
|
||||||
|
|
||||||
|
if fd < 0 {
|
||||||
|
return 0, fmt.Errorf("PTY master is closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return unix.Write(fd, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close closes the PTY master and slave.
|
||||||
|
//
|
||||||
|
// The descriptors are invalidated atomically before closing them so that
|
||||||
|
// concurrent Read or Write calls do not access the descriptor fields
|
||||||
|
// concurrently with Close.
|
||||||
func (p *ptySerial) Close() error {
|
func (p *ptySerial) Close() error {
|
||||||
var firstErr error
|
var firstErr error
|
||||||
|
|
||||||
if p.masterFD >= 0 {
|
masterFD := int(p.masterFD.Swap(-1))
|
||||||
if err := unix.Close(p.masterFD); err != nil {
|
if masterFD >= 0 {
|
||||||
|
if err := unix.Close(masterFD); err != nil {
|
||||||
firstErr = err
|
firstErr = err
|
||||||
}
|
}
|
||||||
p.masterFD = -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.slaveFD >= 0 {
|
slaveFD := int(p.slaveFD.Swap(-1))
|
||||||
if err := unix.Close(p.slaveFD); err != nil && firstErr == nil {
|
if slaveFD >= 0 {
|
||||||
|
if err := unix.Close(slaveFD); err != nil && firstErr == nil {
|
||||||
firstErr = err
|
firstErr = err
|
||||||
}
|
}
|
||||||
p.slaveFD = -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return firstErr
|
return firstErr
|
||||||
|
|
|
||||||
216
internal/client/runtime.go
Normal file
216
internal/client/runtime.go
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
/*
|
||||||
|
* ============================================================================
|
||||||
|
* Projekt.....: rs2322tcp
|
||||||
|
* Datei.......: internal/client/runtime.go
|
||||||
|
* Copyright (C) 2026 Dieter Lang
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Beschreibung:
|
||||||
|
* Clientseitige Laufzeitverwaltung für die konfigurierten virtuellen
|
||||||
|
* seriellen Schnittstellen.
|
||||||
|
*
|
||||||
|
* Die Runtime verbindet die in der Client-Konfiguration angegebenen
|
||||||
|
* virtuellen Ports mit den zugehörigen entfernten Geräten.
|
||||||
|
*
|
||||||
|
* Für jedes konfigurierte virtuelle Gerät wird eine eigene Bridge gestartet:
|
||||||
|
*
|
||||||
|
* /dev/ttyUSBxxx ─── Bridge ─── TCP Data Connection
|
||||||
|
*
|
||||||
|
* Die Control-Verbindung zum Server wird gemeinsam von allen Bridges
|
||||||
|
* verwendet.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Runtime
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Runtime manages the client-side virtual serial connections.
|
||||||
|
//
|
||||||
|
// A Runtime owns the Client control connection and the VirtualPortManager.
|
||||||
|
// Each configured virtual port is represented by one running Bridge.
|
||||||
|
type Runtime struct {
|
||||||
|
client *Client
|
||||||
|
manager *VirtualPortManager
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
bridges []*Bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRuntime creates a new client Runtime.
|
||||||
|
//
|
||||||
|
// The Runtime does not establish a server connection and does not open any
|
||||||
|
// virtual ports until Run is called.
|
||||||
|
func NewRuntime(
|
||||||
|
client *Client,
|
||||||
|
manager *VirtualPortManager,
|
||||||
|
) (*Runtime, error) {
|
||||||
|
if client == nil {
|
||||||
|
return nil, fmt.Errorf("client is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if manager == nil {
|
||||||
|
return nil, fmt.Errorf("virtual port manager is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Runtime{
|
||||||
|
client: client,
|
||||||
|
manager: manager,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Run
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Run starts all configured virtual serial connections.
|
||||||
|
//
|
||||||
|
// The supplied configuration determines which local virtual ports are
|
||||||
|
// assigned to which remote devices.
|
||||||
|
//
|
||||||
|
// Run blocks until one bridge terminates or an error occurs while creating
|
||||||
|
// one of the configured bridges.
|
||||||
|
func (r *Runtime) Run(
|
||||||
|
cfg config.ClientConfig,
|
||||||
|
) error {
|
||||||
|
if r == nil || r.client == nil {
|
||||||
|
return fmt.Errorf("runtime is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.manager == nil {
|
||||||
|
return fmt.Errorf("virtual port manager is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.VirtualPorts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
devices, err := r.client.GetDevices()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get remote devices: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, virtualPortConfig := range cfg.VirtualPorts {
|
||||||
|
bridge, _, _, err := connectBridge(
|
||||||
|
r.client,
|
||||||
|
r.manager,
|
||||||
|
virtualPortConfig,
|
||||||
|
devices,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
r.Close()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
r.bridges = append(r.bridges, bridge)
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.waitForBridge()
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Bridge lifecycle
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// waitForBridge waits until one of the running bridges terminates.
|
||||||
|
//
|
||||||
|
// A bridge terminating is considered the end of the Runtime. The Bridge
|
||||||
|
// itself closes its serial and TCP endpoints when Run returns.
|
||||||
|
func (r *Runtime) waitForBridge() error {
|
||||||
|
results := make(chan error, len(r.bridges))
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
bridges := append([]*Bridge(nil), r.bridges...)
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
for _, bridge := range bridges {
|
||||||
|
go func(b *Bridge) {
|
||||||
|
results <- b.Run()
|
||||||
|
}(bridge)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := <-results
|
||||||
|
|
||||||
|
r.Close()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Close
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Close closes all resources owned by the Runtime.
|
||||||
|
//
|
||||||
|
// Closing the bridge endpoints causes running Bridge.Run calls to terminate.
|
||||||
|
// The client control connection is closed afterwards.
|
||||||
|
//
|
||||||
|
// Calling Close more than once is safe.
|
||||||
|
|
||||||
|
func (r *Runtime) Close() error {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
bridges := append([]*Bridge(nil), r.bridges...)
|
||||||
|
r.bridges = nil
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
var firstErr error
|
||||||
|
|
||||||
|
for _, bridge := range bridges {
|
||||||
|
if bridge == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if bridge.serial != nil {
|
||||||
|
if err := bridge.serial.Close(); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bridge.conn != nil {
|
||||||
|
if err := bridge.conn.Close(); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.client != nil {
|
||||||
|
if err := r.client.Close(); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client returns the Client used by the Runtime.
|
||||||
|
func (r *Runtime) Client() *Client {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager returns the VirtualPortManager used by the Runtime.
|
||||||
|
func (r *Runtime) Manager() *VirtualPortManager {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.manager
|
||||||
|
}
|
||||||
280
internal/client/runtime_integration_test.go
Normal file
280
internal/client/runtime_integration_test.go
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
/*
|
||||||
|
* ============================================================================
|
||||||
|
* Projekt.....: rs2322tcp
|
||||||
|
* Datei.......: internal/client/runtime_integration_test.go
|
||||||
|
* Copyright (C) 2026 Dieter Lang
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Beschreibung:
|
||||||
|
* Integrationstest für die clientseitige Runtime mit einem echten
|
||||||
|
* rs2322tcp-Server und einem virtuellen seriellen Client-Port.
|
||||||
|
*
|
||||||
|
* Der Test verwendet die vorhandene virtuelle serielle Test-Schnittstelle
|
||||||
|
* aus integration_test.go und prüft den vollständigen Datenweg:
|
||||||
|
*
|
||||||
|
* /dev/ttyUSB100
|
||||||
|
* ↕
|
||||||
|
* Bridge
|
||||||
|
* ↕
|
||||||
|
* TCP Data Connection
|
||||||
|
* ↕
|
||||||
|
* rs2322tcp Server
|
||||||
|
* ↕
|
||||||
|
* serielle PTY
|
||||||
|
*
|
||||||
|
* Beide Übertragungsrichtungen werden geprüft.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
package client_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/client"
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Tests
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func TestClientRuntimeSerialIntegration(t *testing.T) {
|
||||||
|
serialA, serialB, cleanup := startVirtualSerialPair(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
const virtualPortPath = "/dev/ttyUSB100"
|
||||||
|
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get user home directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
internalPortPath := filepath.Join(
|
||||||
|
home,
|
||||||
|
".rs2322tcp",
|
||||||
|
"virtual",
|
||||||
|
"ttyUSB100",
|
||||||
|
)
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Server
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
controlProbe, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("find free control port: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
controlPort := controlProbe.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
if err := controlProbe.Close(); err != nil {
|
||||||
|
t.Fatalf("close control probe: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverConfig := &config.ServerConfig{
|
||||||
|
Listen: config.ListenConfig{
|
||||||
|
Address: "127.0.0.1",
|
||||||
|
Port: controlPort,
|
||||||
|
},
|
||||||
|
Devices: []config.DeviceConfig{
|
||||||
|
{
|
||||||
|
ID: "radio",
|
||||||
|
Name: "Funkgerät",
|
||||||
|
SerialPort: serialA,
|
||||||
|
BaudRate: 9600,
|
||||||
|
DataBits: 8,
|
||||||
|
Parity: "none",
|
||||||
|
StopBits: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, err := server.NewControlServer(serverConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewControlServer() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := srv.Listen(); err != nil {
|
||||||
|
t.Fatalf("server Listen() failed: %v", err)
|
||||||
|
}
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = srv.Serve()
|
||||||
|
}()
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Client and Runtime
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
c, err := client.New(srv.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client.New() failed: %v", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
manager := client.NewVirtualPortManager(
|
||||||
|
config.VirtualPortRangeConfig{
|
||||||
|
First: 100,
|
||||||
|
Last: 199,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime, err := client.NewRuntime(c, manager)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRuntime() failed: %v", err)
|
||||||
|
}
|
||||||
|
defer runtime.Close()
|
||||||
|
|
||||||
|
clientConfig := config.ClientConfig{
|
||||||
|
Server: config.ServerConnectionConfig{
|
||||||
|
Address: "127.0.0.1",
|
||||||
|
Port: controlPort,
|
||||||
|
},
|
||||||
|
VirtualPorts: []config.VirtualPortConfig{
|
||||||
|
{
|
||||||
|
Port: virtualPortPath,
|
||||||
|
RemoteDevice: "radio",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Start Runtime
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
runtimeDone := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
runtimeDone <- runtime.Run(clientConfig)
|
||||||
|
}()
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Open client-side virtual serial endpoint
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
var virtualSerial *os.File
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
|
||||||
|
for {
|
||||||
|
virtualSerial, err = os.OpenFile(
|
||||||
|
internalPortPath,
|
||||||
|
os.O_RDWR,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf(
|
||||||
|
"timeout opening virtual serial port %s: %v",
|
||||||
|
internalPortPath,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer virtualSerial.Close()
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Client virtual serial -> Bridge -> Server -> serial
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
clientMessage := []byte("hello from runtime")
|
||||||
|
|
||||||
|
if _, err := virtualSerial.Write(clientMessage); err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"write to virtual serial port failed: %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
serialReceived := make([]byte, len(clientMessage))
|
||||||
|
|
||||||
|
serialPeer, err := os.OpenFile(
|
||||||
|
serialB,
|
||||||
|
os.O_RDWR,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"open server serial peer: %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
defer serialPeer.Close()
|
||||||
|
|
||||||
|
readExactWithTimeout(
|
||||||
|
t,
|
||||||
|
serialPeer,
|
||||||
|
serialReceived,
|
||||||
|
2*time.Second,
|
||||||
|
)
|
||||||
|
|
||||||
|
if string(serialReceived) != string(clientMessage) {
|
||||||
|
t.Fatalf(
|
||||||
|
"serial received %q, want %q",
|
||||||
|
string(serialReceived),
|
||||||
|
string(clientMessage),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Server serial -> Bridge -> Client virtual serial
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
serverMessage := []byte("hello from server")
|
||||||
|
|
||||||
|
if _, err := serialPeer.Write(serverMessage); err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"write to server serial peer failed: %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientReceived := make([]byte, len(serverMessage))
|
||||||
|
|
||||||
|
readExactWithTimeout(
|
||||||
|
t,
|
||||||
|
virtualSerial,
|
||||||
|
clientReceived,
|
||||||
|
2*time.Second,
|
||||||
|
)
|
||||||
|
|
||||||
|
if string(clientReceived) != string(serverMessage) {
|
||||||
|
t.Fatalf(
|
||||||
|
"virtual serial received %q, want %q",
|
||||||
|
string(clientReceived),
|
||||||
|
string(serverMessage),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Terminate Runtime
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
if err := virtualSerial.Close(); err != nil {
|
||||||
|
t.Fatalf("close virtual serial port: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runtime.Close(); err != nil {
|
||||||
|
t.Fatalf("close runtime: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-runtimeDone:
|
||||||
|
// Runtime termination is expected after Runtime.Close().
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("runtime did not terminate after Runtime.Close()")
|
||||||
|
}
|
||||||
|
}
|
||||||
82
internal/client/runtime_test.go
Normal file
82
internal/client/runtime_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
/*
|
||||||
|
* ============================================================================
|
||||||
|
* Projekt.....: rs2322tcp
|
||||||
|
* Datei.......: internal/client/runtime_test.go
|
||||||
|
* Copyright (C) 2026 Dieter Lang
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Beschreibung:
|
||||||
|
* Tests für die clientseitige Runtime.
|
||||||
|
*
|
||||||
|
* Die Tests prüfen die Initialisierung der Runtime und den grundlegenden
|
||||||
|
* Lebenszyklus ohne einen externen rs2322tcp-Server vorauszusetzen.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Tests
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
func TestNewRuntimeNilClient(t *testing.T) {
|
||||||
|
manager := NewVirtualPortManager(
|
||||||
|
config.VirtualPortRangeConfig{
|
||||||
|
First: 100,
|
||||||
|
Last: 199,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err := NewRuntime(nil, manager)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil client")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "client is nil") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRuntimeNilManager(t *testing.T) {
|
||||||
|
client := newTestClient(t)
|
||||||
|
|
||||||
|
_, err := NewRuntime(client, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil manager")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "virtual port manager is nil") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeRunWithoutVirtualPorts(t *testing.T) {
|
||||||
|
client := newTestClient(t)
|
||||||
|
|
||||||
|
manager := NewVirtualPortManager(
|
||||||
|
config.VirtualPortRangeConfig{
|
||||||
|
First: 100,
|
||||||
|
Last: 199,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime, err := NewRuntime(client, manager)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRuntime: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.ClientConfig{
|
||||||
|
VirtualPorts: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runtime.Run(cfg); err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||||
)
|
)
|
||||||
|
|
@ -25,15 +26,23 @@ import (
|
||||||
|
|
||||||
// VirtualPortManager verwaltet den reservierten Bereich virtueller
|
// VirtualPortManager verwaltet den reservierten Bereich virtueller
|
||||||
// /dev/ttyUSBxxx-Schnittstellen.
|
// /dev/ttyUSBxxx-Schnittstellen.
|
||||||
|
//
|
||||||
|
// Der reservierte Portbereich und die Belegungstabelle werden durch einen
|
||||||
|
// Mutex geschützt, da mehrere Bridges beziehungsweise Goroutinen gleichzeitig
|
||||||
|
// virtuelle Ports öffnen und freigeben können.
|
||||||
type VirtualPortManager struct {
|
type VirtualPortManager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
first int
|
first int
|
||||||
last int
|
last int
|
||||||
used map[int]bool
|
used map[int]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewVirtualPortManager erzeugt einen VirtualPortManager aus der
|
// NewVirtualPortManager erzeugt einen VirtualPortManager aus dem
|
||||||
// konfigurierten Portbereich.
|
// konfigurierten Portbereich.
|
||||||
func NewVirtualPortManager(cfg config.VirtualPortRangeConfig) *VirtualPortManager {
|
func NewVirtualPortManager(
|
||||||
|
cfg config.VirtualPortRangeConfig,
|
||||||
|
) *VirtualPortManager {
|
||||||
return &VirtualPortManager{
|
return &VirtualPortManager{
|
||||||
first: cfg.First,
|
first: cfg.First,
|
||||||
last: cfg.Last,
|
last: cfg.Last,
|
||||||
|
|
@ -52,6 +61,7 @@ func (m *VirtualPortManager) PortPath(number int) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reserve reserviert den nächsten freien virtuellen Port.
|
// Reserve reserviert den nächsten freien virtuellen Port.
|
||||||
|
//
|
||||||
// Es wird immer mit dem kleinsten freien Port im konfigurierten Bereich
|
// Es wird immer mit dem kleinsten freien Port im konfigurierten Bereich
|
||||||
// begonnen.
|
// begonnen.
|
||||||
func (m *VirtualPortManager) Reserve() (int, error) {
|
func (m *VirtualPortManager) Reserve() (int, error) {
|
||||||
|
|
@ -59,12 +69,16 @@ func (m *VirtualPortManager) Reserve() (int, error) {
|
||||||
return 0, fmt.Errorf("virtual port manager is nil")
|
return 0, fmt.Errorf("virtual port manager is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
for number := m.first; number <= m.last; number++ {
|
for number := m.first; number <= m.last; number++ {
|
||||||
if m.used[number] {
|
if m.used[number] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
m.used[number] = true
|
m.used[number] = true
|
||||||
|
|
||||||
return number, nil
|
return number, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,5 +95,8 @@ func (m *VirtualPortManager) Release(number int) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
delete(m.used, number)
|
delete(m.used, number)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,10 @@
|
||||||
*/
|
*/
|
||||||
package client
|
package client
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
// Managed virtual port
|
// Managed virtual port
|
||||||
|
|
@ -26,7 +29,12 @@ import "fmt"
|
||||||
|
|
||||||
// ManagedVirtualPort verbindet einen reservierten Port mit einer
|
// ManagedVirtualPort verbindet einen reservierten Port mit einer
|
||||||
// VirtualSerial-Instanz und verwaltet deren Lebenszyklus.
|
// VirtualSerial-Instanz und verwaltet deren Lebenszyklus.
|
||||||
|
//
|
||||||
|
// Der Mutex schützt den internen Port-Pointer gegen gleichzeitige Zugriffe
|
||||||
|
// aus Read, Write und Close.
|
||||||
type ManagedVirtualPort struct {
|
type ManagedVirtualPort struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
port *VirtualPort
|
port *VirtualPort
|
||||||
manager *VirtualPortManager
|
manager *VirtualPortManager
|
||||||
number int
|
number int
|
||||||
|
|
@ -34,7 +42,14 @@ type ManagedVirtualPort struct {
|
||||||
|
|
||||||
// Path returns the device path visible to the external application.
|
// Path returns the device path visible to the external application.
|
||||||
func (p *ManagedVirtualPort) Path() string {
|
func (p *ManagedVirtualPort) Path() string {
|
||||||
if p == nil || p.port == nil {
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
if p.port == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,45 +58,77 @@ func (p *ManagedVirtualPort) Path() string {
|
||||||
|
|
||||||
// Read reads data from the underlying virtual serial device.
|
// Read reads data from the underlying virtual serial device.
|
||||||
func (p *ManagedVirtualPort) Read(b []byte) (int, error) {
|
func (p *ManagedVirtualPort) Read(b []byte) (int, error) {
|
||||||
if p == nil || p.port == nil {
|
if p == nil {
|
||||||
return 0, fmt.Errorf("managed virtual port is nil")
|
return 0, fmt.Errorf("managed virtual port is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.port.Read(b)
|
p.mu.Lock()
|
||||||
|
port := p.port
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if port == nil {
|
||||||
|
return 0, fmt.Errorf("managed virtual port is closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return port.Read(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write writes data to the underlying virtual serial device.
|
// Write writes data to the underlying virtual serial device.
|
||||||
func (p *ManagedVirtualPort) Write(b []byte) (int, error) {
|
func (p *ManagedVirtualPort) Write(b []byte) (int, error) {
|
||||||
if p == nil || p.port == nil {
|
if p == nil {
|
||||||
return 0, fmt.Errorf("managed virtual port is nil")
|
return 0, fmt.Errorf("managed virtual port is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.port.Write(b)
|
p.mu.Lock()
|
||||||
|
port := p.port
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if port == nil {
|
||||||
|
return 0, fmt.Errorf("managed virtual port is closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return port.Write(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close removes the internal link, closes the PTY and releases the
|
// Close removes the internal link, closes the PTY and releases the
|
||||||
// reservation.
|
// reservation.
|
||||||
|
//
|
||||||
|
// The managed port is detached from the object before the underlying PTY
|
||||||
|
// is closed. This prevents a concurrent Read or Write from accessing the
|
||||||
|
// managed port pointer after Close has taken ownership of it.
|
||||||
func (p *ManagedVirtualPort) Close() error {
|
func (p *ManagedVirtualPort) Close() error {
|
||||||
if p == nil || p.port == nil {
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
|
||||||
|
port := p.port
|
||||||
|
p.port = nil
|
||||||
|
|
||||||
|
manager := p.manager
|
||||||
|
number := p.number
|
||||||
|
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if port == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var firstErr error
|
var firstErr error
|
||||||
|
|
||||||
if err := removeVirtualPortLink(p.port.Path()); err != nil {
|
if err := removeVirtualPortLink(port.Path()); err != nil {
|
||||||
firstErr = err
|
firstErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := p.port.Close(); err != nil && firstErr == nil {
|
if err := port.Close(); err != nil && firstErr == nil {
|
||||||
firstErr = err
|
firstErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.manager != nil {
|
if manager != nil {
|
||||||
p.manager.Release(p.number)
|
manager.Release(number)
|
||||||
}
|
}
|
||||||
|
|
||||||
p.port = nil
|
|
||||||
|
|
||||||
return firstErr
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,6 +155,7 @@ func (m *VirtualPortManager) Open() (*ManagedVirtualPort, error) {
|
||||||
serial, err := newVirtualSerial()
|
serial, err := newVirtualSerial()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.Release(number)
|
m.Release(number)
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"create virtual serial for %s: %w",
|
"create virtual serial for %s: %w",
|
||||||
portPath,
|
portPath,
|
||||||
|
|
@ -115,7 +163,10 @@ func (m *VirtualPortManager) Open() (*ManagedVirtualPort, error) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := setVirtualPortLink(portPath, serial.Path()); err != nil {
|
if err := setVirtualPortLink(
|
||||||
|
portPath,
|
||||||
|
serial.Path(),
|
||||||
|
); err != nil {
|
||||||
_ = serial.Close()
|
_ = serial.Close()
|
||||||
m.Release(number)
|
m.Release(number)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue