Implement client control and data connections
This commit is contained in:
parent
24647dfef4
commit
49f72c376a
7 changed files with 928 additions and 40 deletions
47
CHANGELOG.md
47
CHANGELOG.md
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
63
README.md
63
README.md
|
|
@ -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
172
internal/client/control.go
Normal 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
|
||||
}
|
||||
168
internal/client/control_test.go
Normal file
168
internal/client/control_test.go
Normal 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
62
internal/client/data.go
Normal 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
|
||||
}
|
||||
120
internal/client/data_test.go
Normal file
120
internal/client/data_test.go
Normal 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
|
||||
}
|
||||
308
internal/client/integration_test.go
Normal file
308
internal/client/integration_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue