Implement client control and data connections

This commit is contained in:
Dieter Lang 2026-08-10 11:10:37 +02:00
parent 24647dfef4
commit 49f72c376a
7 changed files with 928 additions and 40 deletions

View file

@ -20,6 +20,13 @@ Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert.
- Integrationstest mit virtuellen seriellen Schnittstellen über `socat`
- Tests für TCP → RS232 und RS232 → TCP
- Tests für Session-Reconnect und Ressourcenverwaltung
- Client-Control-Verbindung unter `internal/client`
- Clientseitige Verarbeitung von `HELLO`
- Clientseitige Abfrage der konfigurierten Geräte über `GET_DEVICES`
- Clientseitige Verarbeitung der vom Server gelieferten Geräteinformationen einschließlich dynamischem Data-Port
- Clientseitiger Aufbau der TCP-Data-Verbindung
- Integrationstest für die vollständige Client-/Server-Kommunikation
- End-to-End-Test der TCP-/RS232-Datenübertragung in beide Richtungen
### Changed
@ -36,24 +43,39 @@ Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert.
- `go test ./...` erfolgreich
- `go test -race ./...` erfolgreich
- Race Condition im `DataListener` erkannt und behoben
- Client-/Server-Integrationstest erfolgreich
- Race-Test des Client-/Server-Integrationstests erfolgreich
### Status
Die grundlegende Server-seitige TCP-/RS232-Datenübertragung ist implementiert und durch automatisierte Tests abgesichert.
Die grundlegende TCP-/RS232-Datenübertragung ist serverseitig implementiert und der Client kann inzwischen die Control-Verbindung sowie die zugehörige Data-Verbindung aufbauen.
Der Datenpfad ist derzeit:
Der aktuelle Datenpfad ist:
```text
TCP-Control-Verbindung
Client
├── TCP-Control-Verbindung
│ │
│ ▼
│ Session
│ │
│ ▼
│ GET_DEVICES
│ │
│ ▼
│ Geräteinformationen
│ │
│ ▼
│ dynamischer Data-Port
│ │
└── TCP-Data-Verbindung
Session
DataListener
dynamischer Data-Port
TCP-Data-Verbindung
DataConnection
internal/serial
@ -62,7 +84,14 @@ TCP-Control-Verbindung
RS232-Gerät
```
Die Client-seitige Bereitstellung einer virtuellen seriellen Schnittstelle ist noch nicht implementiert.
Die Datenübertragung wurde bereits in beide Richtungen erfolgreich getestet:
```text
TCP → RS232
RS232 → TCP
```
Die clientseitige Bereitstellung einer virtuellen seriellen Schnittstelle für die vorhandene Hersteller-Software ist noch nicht implementiert.
---

View file

@ -40,28 +40,40 @@ rs2322tcp-client │
Funkgerät Rotor
```
## Aktueller Server-Datenpfad
## Aktueller Datenpfad
Die serverseitige TCP-/RS232-Verbindung ist inzwischen implementiert:
Die serverseitige TCP-/RS232-Verbindung sowie die grundlegende Client-Control- und Client-Data-Kommunikation sind inzwischen implementiert:
```text
TCP-Control
Client
├── TCP-Control
│ │
│ ▼
│ Session
│ │
│ ▼
│ GET_DEVICES
│ │
│ ▼
│ Geräteinformationen
│ │
│ ▼
│ dynamischer Data-Port
└── TCP-Data
Session
DataListener
├── Gerät 1 ──► dynamischer Data-Port
│ │
│ ▼
│ TCP Data
│ │
│ ▼
│ Serial Layer
│ │
│ ▼
│ RS232
DataConnection
└── Gerät 2 ──► dynamischer Data-Port
Serial Layer
RS232
```
Die serielle Schnittstelle wird erst geöffnet, wenn ein Client tatsächlich eine Data-Verbindung zum entsprechenden dynamischen TCP-Port aufbaut.
@ -75,6 +87,8 @@ TCP ───────────────► RS232
TCP ◄────────────── RS232
```
Der komplette Client-/Server-Datenpfad wird inzwischen durch einen Integrationstest mit virtuellen seriellen Schnittstellen geprüft.
## Client
Der Client soll gleichberechtigt unter folgenden Betriebssystemen eingesetzt werden können:
@ -84,7 +98,15 @@ Der Client soll gleichberechtigt unter folgenden Betriebssystemen eingesetzt wer
Die plattformspezifische Bereitstellung der virtuellen seriellen Schnittstelle wird vom gemeinsamen Client-Kern getrennt.
Die Client-seitige virtuelle serielle Schnittstelle ist derzeit noch nicht implementiert.
Der gemeinsame Client-Kern kann inzwischen:
- die Control-Verbindung zum Server aufbauen
- den Control-Handshake durchführen
- die konfigurierten Geräte vom Server abfragen
- den vom Server gelieferten dynamischen Data-Port verwenden
- die TCP-Data-Verbindung zum Server aufbauen
Die clientseitige virtuelle serielle Schnittstelle ist derzeit noch nicht implementiert.
## Server
@ -172,6 +194,8 @@ TCP → RS232
RS232 → TCP
```
Zusätzlich wird die vollständige Client-/Server-Kommunikation über den echten `ControlServer`-Lifecycle (`Listen()` / `Serve()`) getestet.
## Projektstruktur
```text
@ -232,18 +256,23 @@ Release-Versionen werden über Git-Tags gekennzeichnet.
Das Projekt befindet sich weiterhin in der frühen Entwicklungsphase.
Die ursprüngliche Projektgrundlage aus Version `0.0.1` wurde inzwischen um eine funktionierende serverseitige TCP-/RS232-Datenübertragung erweitert.
Die ursprüngliche Projektgrundlage aus Version `0.0.1` wurde inzwischen um eine funktionierende serverseitige TCP-/RS232-Datenübertragung und die grundlegende Client-Control-/Data-Kommunikation erweitert.
Implementiert und getestet sind derzeit:
- Server-Control-Verbindung
- Client-Control-Verbindung
- `HELLO`-Handshake
- `GET_DEVICES`
- Session-Verwaltung
- dynamische Data-Ports
- Clientseitiger Aufbau der Data-Verbindung
- DataConnection
- bidirektionale TCP-/RS232-Datenübertragung
- Serial-Abstraktion
- Konfiguration der seriellen Parameter
- virtuelle serielle Integrationstests
- Client-/Server-End-to-End-Integrationstest
- nebenläufigkeitssichere Data-Listener
- Race-Detection

172
internal/client/control.go Normal file
View file

@ -0,0 +1,172 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: control.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Client-seitige Control-Verbindung zum rs2322tcp-Server.
* Baut die TCP-Control-Verbindung auf, führt den Protokoll-Handshake durch
* und liest die vom Server bereitgestellten Geräteinformationen.
* ============================================================================
*/
package client
import (
"bufio"
"fmt"
"net"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
///////////////////////////////////////////////////////////////////////////////
// Client
///////////////////////////////////////////////////////////////////////////////
// Client represents a connection to an rs2322tcp server.
type Client struct {
conn net.Conn
reader *bufio.Reader
}
///////////////////////////////////////////////////////////////////////////////
// Constructor
///////////////////////////////////////////////////////////////////////////////
// New creates a new client and connects to the specified server address.
//
// The address must be in the form "host:port".
func New(address string) (*Client, error) {
if address == "" {
return nil, fmt.Errorf("server address is empty")
}
conn, err := net.Dial("tcp", address)
if err != nil {
return nil, fmt.Errorf(
"connect to server %q: %w",
address,
err,
)
}
client := &Client{
conn: conn,
reader: bufio.NewReader(conn),
}
if err := client.hello(); err != nil {
_ = conn.Close()
return nil, err
}
return client, nil
}
///////////////////////////////////////////////////////////////////////////////
// Properties
///////////////////////////////////////////////////////////////////////////////
// Conn returns the underlying control connection.
//
// It returns nil if the client is nil or has already been closed.
func (c *Client) Conn() net.Conn {
if c == nil {
return nil
}
return c.conn
}
///////////////////////////////////////////////////////////////////////////////
// Handshake
///////////////////////////////////////////////////////////////////////////////
func (c *Client) hello() error {
if c == nil || c.conn == nil {
return fmt.Errorf("client is not connected")
}
if err := transport.WriteMessage(
c.conn,
transport.NewHello(),
); err != nil {
return fmt.Errorf("send hello: %w", err)
}
var response transport.HelloResponseMessage
if err := transport.ReadMessage(
c.reader,
&response,
); err != nil {
return fmt.Errorf("read hello response: %w", err)
}
if response.Type != transport.MessageHelloResponse {
return fmt.Errorf(
"unexpected hello response: %q",
response.Type,
)
}
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Devices
///////////////////////////////////////////////////////////////////////////////
// GetDevices requests the configured remote devices from the server.
//
// The returned list contains the session-specific dynamic TCP data ports.
func (c *Client) GetDevices() ([]transport.RemoteDeviceInfo, error) {
if c == nil || c.conn == nil {
return nil, fmt.Errorf("client is not connected")
}
if err := transport.WriteMessage(
c.conn,
transport.NewGetDevices(),
); err != nil {
return nil, fmt.Errorf("send get-devices request: %w", err)
}
var response transport.DeviceListMessage
if err := transport.ReadMessage(
c.reader,
&response,
); err != nil {
return nil, fmt.Errorf("read device list: %w", err)
}
if response.Type != transport.MessageDeviceList {
return nil, fmt.Errorf(
"unexpected device list response: %q",
response.Type,
)
}
return response.Devices, nil
}
///////////////////////////////////////////////////////////////////////////////
// Close
///////////////////////////////////////////////////////////////////////////////
// Close closes the client control connection.
func (c *Client) Close() error {
if c == nil || c.conn == nil {
return nil
}
err := c.conn.Close()
c.conn = nil
return err
}

View file

@ -0,0 +1,168 @@
package client_test
import (
"bufio"
"net"
"testing"
"git.lang-dieter.de/rs2322tcp/internal/client"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
func startTestServer(t *testing.T, handler func(net.Conn)) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen failed: %v", err)
}
t.Cleanup(func() {
_ = listener.Close()
})
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
handler(conn)
}()
return listener.Addr().String()
}
func TestClientHello(t *testing.T) {
address := startTestServer(t, func(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
var hello transport.HelloMessage
if err := transport.ReadMessage(reader, &hello); err != nil {
return
}
if hello.Type != transport.MessageHello {
return
}
_ = transport.WriteMessage(
conn,
transport.NewHelloResponse(),
)
})
c, err := client.New(address)
if err != nil {
t.Fatalf("client.New() failed: %v", err)
}
defer c.Close()
}
func TestClientGetDevices(t *testing.T) {
address := startTestServer(t, func(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
// HELLO
var hello transport.HelloMessage
if err := transport.ReadMessage(reader, &hello); err != nil {
return
}
if hello.Type != transport.MessageHello {
return
}
if err := transport.WriteMessage(
conn,
transport.NewHelloResponse(),
); err != nil {
return
}
// GET_DEVICES
var request transport.GetDevicesMessage
if err := transport.ReadMessage(reader, &request); err != nil {
return
}
if request.Type != transport.MessageGetDevices {
return
}
response := transport.NewDeviceList([]transport.RemoteDeviceInfo{
{
ID: "radio",
Name: "Funkgerät",
DataPort: 4711,
BaudRate: 9600,
DataBits: 8,
Parity: "none",
StopBits: 1,
},
{
ID: "rotor",
Name: "Antennenrotor",
DataPort: 4712,
BaudRate: 4800,
DataBits: 8,
Parity: "none",
StopBits: 1,
},
})
_ = transport.WriteMessage(conn, response)
})
c, err := client.New(address)
if err != nil {
t.Fatalf("client.New() failed: %v", err)
}
defer c.Close()
devices, err := c.GetDevices()
if err != nil {
t.Fatalf("GetDevices() failed: %v", err)
}
if len(devices) != 2 {
t.Fatalf("len(devices) = %d, want 2", len(devices))
}
if devices[0].ID != "radio" {
t.Errorf(
"devices[0].ID = %q, want %q",
devices[0].ID,
"radio",
)
}
if devices[0].DataPort != 4711 {
t.Errorf(
"devices[0].DataPort = %d, want 4711",
devices[0].DataPort,
)
}
if devices[1].ID != "rotor" {
t.Errorf(
"devices[1].ID = %q, want %q",
devices[1].ID,
"rotor",
)
}
if devices[1].DataPort != 4712 {
t.Errorf(
"devices[1].DataPort = %d, want 4712",
devices[1].DataPort,
)
}
}

62
internal/client/data.go Normal file
View file

@ -0,0 +1,62 @@
package client
import (
"fmt"
"net"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
///////////////////////////////////////////////////////////////////////////////
// Data connection
///////////////////////////////////////////////////////////////////////////////
// OpenDataConnection opens the TCP data connection for the specified
// remote device.
//
// The DataPort is supplied by the server as part of the device information.
func (c *Client) OpenDataConnection(
device transport.RemoteDeviceInfo,
) (net.Conn, error) {
if c == nil || c.conn == nil {
return nil, fmt.Errorf("client is not connected")
}
if device.DataPort == 0 {
return nil, fmt.Errorf(
"device %q has no data port",
device.ID,
)
}
serverAddr := c.conn.RemoteAddr()
if serverAddr == nil {
return nil, fmt.Errorf("control connection has no remote address")
}
host, _, err := net.SplitHostPort(serverAddr.String())
if err != nil {
return nil, fmt.Errorf(
"invalid control server address %q: %w",
serverAddr.String(),
err,
)
}
dataAddr := net.JoinHostPort(
host,
fmt.Sprintf("%d", device.DataPort),
)
conn, err := net.Dial("tcp", dataAddr)
if err != nil {
return nil, fmt.Errorf(
"connect data port %d for device %q: %w",
device.DataPort,
device.ID,
err,
)
}
return conn, nil
}

View file

@ -0,0 +1,120 @@
package client_test
import (
"bufio"
"net"
"testing"
"git.lang-dieter.de/rs2322tcp/internal/client"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
func TestClientOpenDataConnection(t *testing.T) {
dataListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("data listener failed: %v", err)
}
defer dataListener.Close()
dataPort := dataListener.Addr().(*net.TCPAddr).Port
controlListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("control listener failed: %v", err)
}
defer controlListener.Close()
serverDone := make(chan error, 1)
go func() {
conn, err := controlListener.Accept()
if err != nil {
serverDone <- err
return
}
defer conn.Close()
reader := bufio.NewReader(conn)
var hello transport.HelloMessage
if err := transport.ReadMessage(reader, &hello); err != nil {
serverDone <- err
return
}
if hello.Type != transport.MessageHello {
serverDone <- &testError{
message: "unexpected hello message type",
}
return
}
if err := transport.WriteMessage(
conn,
transport.NewHelloResponse(),
); err != nil {
serverDone <- err
return
}
serverDone <- nil
}()
clientConn, err := client.New(controlListener.Addr().String())
if err != nil {
t.Fatalf("client.New() failed: %v", err)
}
defer clientConn.Close()
device := transport.RemoteDeviceInfo{
ID: "radio",
Name: "Funkgerät",
DataPort: dataPort,
}
dataConn, err := clientConn.OpenDataConnection(device)
if err != nil {
t.Fatalf("OpenDataConnection() failed: %v", err)
}
defer dataConn.Close()
serverDataConn, err := dataListener.Accept()
if err != nil {
t.Fatalf("data listener Accept() failed: %v", err)
}
defer serverDataConn.Close()
if err := <-serverDone; err != nil {
t.Fatalf("control server failed: %v", err)
}
// Verify that the data connection is actually usable.
message := []byte("test")
if _, err := dataConn.Write(message); err != nil {
t.Fatalf("data connection Write() failed: %v", err)
}
buffer := make([]byte, len(message))
if _, err := serverDataConn.Read(buffer); err != nil {
t.Fatalf("data connection Read() failed: %v", err)
}
if string(buffer) != string(message) {
t.Fatalf(
"received %q, want %q",
string(buffer),
string(message),
)
}
}
type testError struct {
message string
}
func (e *testError) Error() string {
return e.message
}

View file

@ -0,0 +1,308 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: integration_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Integrationstest für die Client-/Server-Kommunikation einschließlich
* serieller Datenübertragung über virtuelle serielle Schnittstellen.
* ============================================================================
*/
package client_test
import (
"fmt"
"io"
"net"
"os"
"os/exec"
"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"
)
func startVirtualSerialPair(t *testing.T) (string, string, func()) {
t.Helper()
if _, err := exec.LookPath("socat"); err != nil {
t.Skip("socat not installed")
}
dir := t.TempDir()
portA := filepath.Join(dir, "ttyA")
portB := filepath.Join(dir, "ttyB")
cmd := exec.Command(
"socat",
"-d",
"-d",
fmt.Sprintf("pty,raw,echo=0,link=%s", portA),
fmt.Sprintf("pty,raw,echo=0,link=%s", portB),
)
if err := cmd.Start(); err != nil {
t.Fatalf("start socat: %v", err)
}
cleanup := func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
}
}
deadline := time.Now().Add(2 * time.Second)
for {
_, errA := os.Stat(portA)
_, errB := os.Stat(portB)
if errA == nil && errB == nil {
return portA, portB, cleanup
}
if time.Now().After(deadline) {
cleanup()
t.Fatal("timeout waiting for virtual serial ports")
}
time.Sleep(10 * time.Millisecond)
}
}
func readExactWithTimeout(
t *testing.T,
reader io.Reader,
buffer []byte,
timeout time.Duration,
) {
t.Helper()
done := make(chan error, 1)
go func() {
_, err := io.ReadFull(reader, buffer)
done <- err
}()
select {
case err := <-done:
if err != nil {
t.Fatalf("read failed: %v", err)
}
case <-time.After(timeout):
t.Fatalf("read timeout after %s", timeout)
}
}
func TestClientServerSerialIntegration(t *testing.T) {
serialA, serialB, cleanup := startVirtualSerialPair(t)
defer cleanup()
////////////////////////////////////////////////////////////////////////////
// Server
////////////////////////////////////////////////////////////////////////////
controlProbe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("find free control port: %v", err)
}
controlAddr := controlProbe.Addr().(*net.TCPAddr)
controlPort := controlAddr.Port
if err := controlProbe.Close(); err != nil {
t.Fatalf("close control probe: %v", err)
}
cfg := &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(cfg)
if err != nil {
t.Fatalf("NewControlServer() failed: %v", err)
}
////////////////////////////////////////////////////////////////////////////
// Control server
////////////////////////////////////////////////////////////////////////////
if err := srv.Listen(); err != nil {
t.Fatalf("server Listen() failed: %v", err)
}
serveDone := make(chan error, 1)
go func() {
serveDone <- srv.Serve()
}()
address := srv.Addr().String()
////////////////////////////////////////////////////////////////////////////
// Client
////////////////////////////////////////////////////////////////////////////
c, err := client.New(address)
if err != nil {
t.Fatalf("client.New() failed: %v", err)
}
defer c.Close()
devices, err := c.GetDevices()
if err != nil {
t.Fatalf("GetDevices() failed: %v", err)
}
if len(devices) != 1 {
t.Fatalf(
"len(devices) = %d, want 1",
len(devices),
)
}
device := devices[0]
if device.ID != "radio" {
t.Fatalf(
"device ID = %q, want %q",
device.ID,
"radio",
)
}
if device.DataPort == 0 {
t.Fatal("server returned data port 0")
}
////////////////////////////////////////////////////////////////////////////
// Open data connection
////////////////////////////////////////////////////////////////////////////
dataConn, err := c.OpenDataConnection(device)
if err != nil {
t.Fatalf("OpenDataConnection() failed: %v", err)
}
defer dataConn.Close()
////////////////////////////////////////////////////////////////////////////
// Open serial peer
////////////////////////////////////////////////////////////////////////////
serialPeer, err := os.OpenFile(
serialB,
os.O_RDWR,
0,
)
if err != nil {
t.Fatalf("open serial peer: %v", err)
}
defer serialPeer.Close()
////////////////////////////////////////////////////////////////////////////
// Client -> TCP -> Server -> Serial
////////////////////////////////////////////////////////////////////////////
clientMessage := []byte("hello from client")
if _, err := dataConn.Write(clientMessage); err != nil {
t.Fatalf("client data Write() failed: %v", err)
}
serialReceived := make([]byte, len(clientMessage))
readExactWithTimeout(
t,
serialPeer,
serialReceived,
2*time.Second,
)
if string(serialReceived) != string(clientMessage) {
t.Fatalf(
"serial received %q, want %q",
string(serialReceived),
string(clientMessage),
)
}
////////////////////////////////////////////////////////////////////////////
// Serial -> Server -> TCP -> Client
////////////////////////////////////////////////////////////////////////////
serialMessage := []byte("hello from serial")
if _, err := serialPeer.Write(serialMessage); err != nil {
t.Fatalf("serial Write() failed: %v", err)
}
clientReceived := make([]byte, len(serialMessage))
readExactWithTimeout(
t,
dataConn,
clientReceived,
2*time.Second,
)
if string(clientReceived) != string(serialMessage) {
t.Fatalf(
"client received %q, want %q",
string(clientReceived),
string(serialMessage),
)
}
////////////////////////////////////////////////////////////////////////////
// Cleanup
////////////////////////////////////////////////////////////////////////////
if err := dataConn.Close(); err != nil {
t.Fatalf("close data connection: %v", err)
}
if err := c.Close(); err != nil {
t.Fatalf("close client: %v", err)
}
// Give the server goroutines a moment to perform their cleanup.
time.Sleep(20 * time.Millisecond)
if err := srv.Close(); err != nil {
t.Fatalf("close server: %v", err)
}
select {
case err := <-serveDone:
if err != nil {
t.Fatalf("server Serve() failed: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for server Serve() to stop")
}
}