Implement client control and data connections
This commit is contained in:
parent
24647dfef4
commit
49f72c376a
7 changed files with 928 additions and 40 deletions
67
CHANGELOG.md
67
CHANGELOG.md
|
|
@ -20,6 +20,13 @@ Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert.
|
||||||
- Integrationstest mit virtuellen seriellen Schnittstellen über `socat`
|
- Integrationstest mit virtuellen seriellen Schnittstellen über `socat`
|
||||||
- Tests für TCP → RS232 und RS232 → TCP
|
- Tests für TCP → RS232 und RS232 → TCP
|
||||||
- Tests für Session-Reconnect und Ressourcenverwaltung
|
- 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
|
### Changed
|
||||||
|
|
||||||
|
|
@ -36,33 +43,55 @@ Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert.
|
||||||
- `go test ./...` erfolgreich
|
- `go test ./...` erfolgreich
|
||||||
- `go test -race ./...` erfolgreich
|
- `go test -race ./...` erfolgreich
|
||||||
- Race Condition im `DataListener` erkannt und behoben
|
- Race Condition im `DataListener` erkannt und behoben
|
||||||
|
- Client-/Server-Integrationstest erfolgreich
|
||||||
|
- Race-Test des Client-/Server-Integrationstests erfolgreich
|
||||||
|
|
||||||
### Status
|
### 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
|
```text
|
||||||
TCP-Control-Verbindung
|
Client
|
||||||
│
|
│
|
||||||
▼
|
├── TCP-Control-Verbindung
|
||||||
Session
|
│ │
|
||||||
│
|
│ ▼
|
||||||
▼
|
│ Session
|
||||||
dynamischer Data-Port
|
│ │
|
||||||
│
|
│ ▼
|
||||||
▼
|
│ GET_DEVICES
|
||||||
TCP-Data-Verbindung
|
│ │
|
||||||
│
|
│ ▼
|
||||||
▼
|
│ Geräteinformationen
|
||||||
internal/serial
|
│ │
|
||||||
│
|
│ ▼
|
||||||
▼
|
│ dynamischer Data-Port
|
||||||
RS232-Gerät
|
│ │
|
||||||
|
└── TCP-Data-Verbindung
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DataListener
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DataConnection
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
internal/serial
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
71
README.md
71
README.md
|
|
@ -40,28 +40,40 @@ rs2322tcp-client │
|
||||||
Funkgerät Rotor
|
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
|
```text
|
||||||
TCP-Control
|
Client
|
||||||
│
|
│
|
||||||
▼
|
├── TCP-Control
|
||||||
Session
|
│ │
|
||||||
│
|
│ ▼
|
||||||
├── Gerät 1 ──► dynamischer Data-Port
|
│ Session
|
||||||
│ │
|
│ │
|
||||||
│ ▼
|
│ ▼
|
||||||
│ TCP Data
|
│ GET_DEVICES
|
||||||
│ │
|
│ │
|
||||||
│ ▼
|
│ ▼
|
||||||
│ Serial Layer
|
│ Geräteinformationen
|
||||||
│ │
|
│ │
|
||||||
│ ▼
|
│ ▼
|
||||||
│ RS232
|
│ dynamischer Data-Port
|
||||||
│
|
│
|
||||||
└── Gerät 2 ──► dynamischer Data-Port
|
└── TCP-Data
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DataListener
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DataConnection
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Serial Layer
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RS232
|
||||||
```
|
```
|
||||||
|
|
||||||
Die serielle Schnittstelle wird erst geöffnet, wenn ein Client tatsächlich eine Data-Verbindung zum entsprechenden dynamischen TCP-Port aufbaut.
|
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
|
TCP ◄────────────── RS232
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Der komplette Client-/Server-Datenpfad wird inzwischen durch einen Integrationstest mit virtuellen seriellen Schnittstellen geprüft.
|
||||||
|
|
||||||
## Client
|
## Client
|
||||||
|
|
||||||
Der Client soll gleichberechtigt unter folgenden Betriebssystemen eingesetzt werden können:
|
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 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
|
## Server
|
||||||
|
|
||||||
|
|
@ -172,6 +194,8 @@ TCP → RS232
|
||||||
RS232 → TCP
|
RS232 → TCP
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Zusätzlich wird die vollständige Client-/Server-Kommunikation über den echten `ControlServer`-Lifecycle (`Listen()` / `Serve()`) getestet.
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -232,18 +256,23 @@ Release-Versionen werden über Git-Tags gekennzeichnet.
|
||||||
|
|
||||||
Das Projekt befindet sich weiterhin in der frühen Entwicklungsphase.
|
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:
|
Implementiert und getestet sind derzeit:
|
||||||
|
|
||||||
- Server-Control-Verbindung
|
- Server-Control-Verbindung
|
||||||
|
- Client-Control-Verbindung
|
||||||
|
- `HELLO`-Handshake
|
||||||
|
- `GET_DEVICES`
|
||||||
- Session-Verwaltung
|
- Session-Verwaltung
|
||||||
- dynamische Data-Ports
|
- dynamische Data-Ports
|
||||||
|
- Clientseitiger Aufbau der Data-Verbindung
|
||||||
- DataConnection
|
- DataConnection
|
||||||
- bidirektionale TCP-/RS232-Datenübertragung
|
- bidirektionale TCP-/RS232-Datenübertragung
|
||||||
- Serial-Abstraktion
|
- Serial-Abstraktion
|
||||||
- Konfiguration der seriellen Parameter
|
- Konfiguration der seriellen Parameter
|
||||||
- virtuelle serielle Integrationstests
|
- virtuelle serielle Integrationstests
|
||||||
|
- Client-/Server-End-to-End-Integrationstest
|
||||||
- nebenläufigkeitssichere Data-Listener
|
- nebenläufigkeitssichere Data-Listener
|
||||||
- Race-Detection
|
- 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