Implement server-side RS232 data transport

This commit is contained in:
Dieter Lang 2026-08-10 10:38:14 +02:00
parent 6fa2293c77
commit 24647dfef4
26 changed files with 5460 additions and 4 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
Notizen.txt
# Build-Verzeichnis # Build-Verzeichnis
/build/ /build/

View file

@ -2,6 +2,70 @@
Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert. Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert.
## [Unreleased]
### Added
- Implementierung der seriellen Schnittstellenschicht unter `internal/serial`
- Verwendung von `go.bug.st/serial v1.7.1` für die Ansteuerung der seriellen Schnittstellen
- Konfigurationsunterstützung für Baudrate, Datenbits, Parität und Stopbits
- Öffnen und Schließen serieller Schnittstellen über die interne Serial-Abstraktion
- Lesen und Schreiben von RS232-Daten über die Serial-Abstraktion
- Dynamische TCP-Data-Listener für konfigurierte Geräte
- Session-Verwaltung für dynamische Data-Listener
- Session-Verwaltung für aktive DataConnections
- Bidirektionale Datenübertragung zwischen TCP und RS232
- Öffnen der seriellen Schnittstelle erst beim Aufbau einer tatsächlichen Data-Verbindung
- Begrenzung auf eine aktive Data-Verbindung pro konfiguriertem Gerät
- Integrationstest mit virtuellen seriellen Schnittstellen über `socat`
- Tests für TCP → RS232 und RS232 → TCP
- Tests für Session-Reconnect und Ressourcenverwaltung
### Changed
- Go-Version des Projektes auf Go `1.25.0` aktualisiert
- `go.bug.st/serial` wird in Version `v1.7.1` verwendet
- `golang.org/x/sys v0.43.0` wird als indirekte Abhängigkeit verwendet
- Die Serial-Bibliothek `go.bug.st/serial` wurde zusätzlich auf dem privaten Git-Server des Projektes als Ausfallsicherung gespiegelt:
`git.lang-dieter.de/third-party/go-serial`
- Der `DataListener` wurde nebenläufigkeitssicher implementiert
- Das Schließen eines `DataListener` kann gleichzeitig mit einem laufenden `Accept()` erfolgen
### Tests
- `go test ./...` erfolgreich
- `go test -race ./...` erfolgreich
- Race Condition im `DataListener` erkannt und behoben
### Status
Die grundlegende Server-seitige TCP-/RS232-Datenübertragung ist implementiert und durch automatisierte Tests abgesichert.
Der Datenpfad ist derzeit:
```text
TCP-Control-Verbindung
Session
dynamischer Data-Port
TCP-Data-Verbindung
internal/serial
RS232-Gerät
```
Die Client-seitige Bereitstellung einer virtuellen seriellen Schnittstelle ist noch nicht implementiert.
---
## [0.0.1] - 2026-08-09 ## [0.0.1] - 2026-08-09
### Added ### Added

101
README.md
View file

@ -40,6 +40,41 @@ rs2322tcp-client │
Funkgerät Rotor Funkgerät Rotor
``` ```
## Aktueller Server-Datenpfad
Die serverseitige TCP-/RS232-Verbindung ist inzwischen implementiert:
```text
TCP-Control
Session
├── Gerät 1 ──► dynamischer Data-Port
│ │
│ ▼
│ TCP Data
│ │
│ ▼
│ Serial Layer
│ │
│ ▼
│ RS232
└── Gerät 2 ──► dynamischer Data-Port
```
Die serielle Schnittstelle wird erst geöffnet, wenn ein Client tatsächlich eine Data-Verbindung zum entsprechenden dynamischen TCP-Port aufbaut.
Pro konfiguriertem Gerät ist nur eine aktive Data-Verbindung vorgesehen.
Die Datenübertragung erfolgt bidirektional:
```text
TCP ───────────────► RS232
TCP ◄────────────── RS232
```
## Client ## Client
Der Client soll gleichberechtigt unter folgenden Betriebssystemen eingesetzt werden können: Der Client soll gleichberechtigt unter folgenden Betriebssystemen eingesetzt werden können:
@ -49,12 +84,32 @@ 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.
## Server ## Server
Der Server ist zunächst für den Betrieb auf einem Raspberry Pi 5 vorgesehen. Der Server ist zunächst für den Betrieb auf einem Raspberry Pi 5 vorgesehen.
Mehrere USB-to-RS232-Adapter können angeschlossen werden. Anzahl, Bezeichnung und serielle Parameter der Anschlüsse werden über eine Konfigurationsdatei festgelegt. Mehrere USB-to-RS232-Adapter können angeschlossen werden. Anzahl, Bezeichnung und serielle Parameter der Anschlüsse werden über eine Konfigurationsdatei festgelegt.
Die serverseitige Serial-Kommunikation verwendet `go.bug.st/serial`.
## Serielle Schnittstelle
Für die Ansteuerung der seriellen Schnittstellen wird derzeit verwendet:
```text
go.bug.st/serial v1.7.1
```
Die Bibliothek wurde zusätzlich auf dem privaten Git-Server des Projektes gespiegelt, um bei einem Ausfall des ursprünglichen Anbieters weiterhin auf den verwendeten Quellstand zugreifen zu können:
```text
git.lang-dieter.de/third-party/go-serial
```
Die verwendete Version `v1.7.1` ist auf dem Backup-Repository einschließlich Git-Tag vorhanden.
## Netzwerk ## Netzwerk
Für die Übertragung der RS232-Daten wird TCP verwendet. Für die Übertragung der RS232-Daten wird TCP verwendet.
@ -90,6 +145,33 @@ Insbesondere soll eine Darstellung der übertragenen Bytes möglich sein, um Pro
Während der Entwicklung kann `socat` als zusätzliches Werkzeug für Tests und Diagnose eingesetzt werden. Während der Entwicklung kann `socat` als zusätzliches Werkzeug für Tests und Diagnose eingesetzt werden.
## Tests
Die wichtigsten Komponenten verfügen über automatisierte Tests.
Der aktuelle Stand wird unter anderem mit folgenden Befehlen geprüft:
```bash
go test ./...
```
und:
```bash
go test -race ./...
```
Der Race Detector wird eingesetzt, um Probleme bei der nebenläufigen Verarbeitung von Sessions, Data-Listenern und DataConnections zu erkennen.
Für die serielle Datenübertragung werden virtuelle serielle Schnittstellen über `socat` verwendet. Dadurch kann der Datenpfad ohne angeschlossene RS232-Hardware getestet werden.
Dabei werden insbesondere beide Übertragungsrichtungen geprüft:
```text
TCP → RS232
RS232 → TCP
```
## Projektstruktur ## Projektstruktur
```text ```text
@ -148,11 +230,24 @@ Release-Versionen werden über Git-Tags gekennzeichnet.
## Entwicklungsstand ## Entwicklungsstand
Das Projekt befindet sich derzeit in der frühen Entwicklungsphase. Das Projekt befindet sich weiterhin in der frühen Entwicklungsphase.
Version `0.0.1` enthält zunächst die Projektgrundlage, die beiden Programmgerüste sowie das Buildsystem. Die ursprüngliche Projektgrundlage aus Version `0.0.1` wurde inzwischen um eine funktionierende serverseitige TCP-/RS232-Datenübertragung erweitert.
Eine funktionierende RS232- oder TCP-Übertragung ist in dieser Version noch nicht implementiert. Implementiert und getestet sind derzeit:
- Server-Control-Verbindung
- Session-Verwaltung
- dynamische Data-Ports
- DataConnection
- bidirektionale TCP-/RS232-Datenübertragung
- Serial-Abstraktion
- Konfiguration der seriellen Parameter
- virtuelle serielle Integrationstests
- nebenläufigkeitssichere Data-Listener
- Race-Detection
Noch nicht implementiert ist insbesondere die clientseitige virtuelle serielle Schnittstelle für Windows und Linux.
## Lizenz ## Lizenz

16
configs/client.json Normal file
View file

@ -0,0 +1,16 @@
{
"server": {
"address": "100.64.0.10",
"port": 5000
},
"virtual_ports": [
{
"port": "COM7",
"remote_device": "radio"
},
{
"port": "COM8",
"remote_device": "rotor"
}
]
}

26
configs/server.json Normal file
View file

@ -0,0 +1,26 @@
{
"listen": {
"address": "0.0.0.0",
"port": 5000
},
"devices": [
{
"id": "radio",
"name": "Funkgerät",
"serial_port": "/dev/ttyUSB0",
"baud_rate": 9600,
"data_bits": 8,
"parity": "none",
"stop_bits": 1
},
{
"id": "rotor",
"name": "Antennenrotor",
"serial_port": "/dev/ttyUSB1",
"baud_rate": 4800,
"data_bits": 8,
"parity": "none",
"stop_bits": 1
}
]
}

6
go.mod
View file

@ -1,3 +1,7 @@
module git.lang-dieter.de/rs2322tcp module git.lang-dieter.de/rs2322tcp
go 1.22.2 go 1.25.0
require go.bug.st/serial v1.7.1
require golang.org/x/sys v0.43.0 // indirect

4
go.sum Normal file
View file

@ -0,0 +1,4 @@
go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc=
go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

245
internal/config/config.go Normal file
View file

@ -0,0 +1,245 @@
/*
Package config provides configuration types and JSON handling for rs2322tcp.
The server configuration describes the physical serial devices available
on the remote system. The client configuration describes the connection
to the server and the user's assignment of virtual serial ports to remote
devices.
Project: rs2322tcp
Module: git.lang-dieter.de/rs2322tcp
*/
package config
import (
"encoding/json"
"fmt"
"os"
)
///////////////////////////////////////////////////////////////////////////////
// Server configuration
///////////////////////////////////////////////////////////////////////////////
// ServerConfig contains the complete server configuration.
type ServerConfig struct {
Listen ListenConfig `json:"listen"`
Devices []DeviceConfig `json:"devices"`
}
// ListenConfig contains the network listener configuration.
type ListenConfig struct {
Address string `json:"address"`
Port int `json:"port"`
}
// DeviceConfig describes one physical RS232 device connected to the server.
type DeviceConfig struct {
ID string `json:"id"`
Name string `json:"name"`
SerialPort string `json:"serial_port"`
BaudRate int `json:"baud_rate"`
DataBits int `json:"data_bits"`
Parity string `json:"parity"`
StopBits int `json:"stop_bits"`
}
///////////////////////////////////////////////////////////////////////////////
// Client configuration
///////////////////////////////////////////////////////////////////////////////
// ClientConfig contains the complete client configuration.
type ClientConfig struct {
Server ServerConnectionConfig `json:"server"`
VirtualPorts []VirtualPortConfig `json:"virtual_ports"`
}
// ServerConnectionConfig contains the connection information for the
// rs2322tcp server.
type ServerConnectionConfig struct {
Address string `json:"address"`
Port int `json:"port"`
}
// VirtualPortConfig describes one local virtual serial port and the
// remote device assigned to it.
type VirtualPortConfig struct {
Port string `json:"port"`
RemoteDevice string `json:"remote_device"`
}
///////////////////////////////////////////////////////////////////////////////
// JSON loading
///////////////////////////////////////////////////////////////////////////////
// LoadServer loads a server configuration from a JSON file.
func LoadServer(filename string) (*ServerConfig, error) {
var cfg ServerConfig
if err := loadJSON(filename, &cfg); err != nil {
return nil, err
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return &cfg, nil
}
// LoadClient loads a client configuration from a JSON file.
func LoadClient(filename string) (*ClientConfig, error) {
var cfg ClientConfig
if err := loadJSON(filename, &cfg); err != nil {
return nil, err
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return &cfg, nil
}
///////////////////////////////////////////////////////////////////////////////
// JSON saving
///////////////////////////////////////////////////////////////////////////////
// SaveServer saves a server configuration as formatted JSON.
func SaveServer(filename string, cfg *ServerConfig) error {
if cfg == nil {
return fmt.Errorf("server configuration is nil")
}
if err := cfg.Validate(); err != nil {
return err
}
return saveJSON(filename, cfg)
}
// SaveClient saves a client configuration as formatted JSON.
func SaveClient(filename string, cfg *ClientConfig) error {
if cfg == nil {
return fmt.Errorf("client configuration is nil")
}
if err := cfg.Validate(); err != nil {
return err
}
return saveJSON(filename, cfg)
}
///////////////////////////////////////////////////////////////////////////////
// Validation
///////////////////////////////////////////////////////////////////////////////
// Validate checks the server configuration for basic errors.
func (cfg *ServerConfig) Validate() error {
if cfg == nil {
return fmt.Errorf("server configuration is nil")
}
if cfg.Listen.Port < 1 || cfg.Listen.Port > 65535 {
return fmt.Errorf("invalid listen port: %d", cfg.Listen.Port)
}
ids := make(map[string]bool)
for i, device := range cfg.Devices {
if device.ID == "" {
return fmt.Errorf("device %d: ID is empty", i)
}
if device.SerialPort == "" {
return fmt.Errorf("device %q: serial port is empty", device.ID)
}
if ids[device.ID] {
return fmt.Errorf("duplicate device ID: %q", device.ID)
}
ids[device.ID] = true
if device.BaudRate <= 0 {
return fmt.Errorf("device %q: invalid baud rate", device.ID)
}
if device.DataBits < 5 || device.DataBits > 8 {
return fmt.Errorf("device %q: invalid data bits", device.ID)
}
if device.StopBits < 1 || device.StopBits > 2 {
return fmt.Errorf("device %q: invalid stop bits", device.ID)
}
}
return nil
}
// Validate checks the client configuration for basic errors.
func (cfg *ClientConfig) Validate() error {
if cfg == nil {
return fmt.Errorf("client configuration is nil")
}
if cfg.Server.Port < 1 || cfg.Server.Port > 65535 {
return fmt.Errorf("invalid server port: %d", cfg.Server.Port)
}
ports := make(map[string]bool)
for i, virtualPort := range cfg.VirtualPorts {
if virtualPort.Port == "" {
return fmt.Errorf("virtual port %d: port is empty", i)
}
if virtualPort.RemoteDevice == "" {
return fmt.Errorf("virtual port %q: remote device is empty",
virtualPort.Port)
}
if ports[virtualPort.Port] {
return fmt.Errorf("duplicate virtual port: %q",
virtualPort.Port)
}
ports[virtualPort.Port] = true
}
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Internal JSON helpers
///////////////////////////////////////////////////////////////////////////////
func loadJSON(filename string, target interface{}) error {
data, err := os.ReadFile(filename)
if err != nil {
return fmt.Errorf("read configuration %q: %w", filename, err)
}
if err := json.Unmarshal(data, target); err != nil {
return fmt.Errorf("parse configuration %q: %w", filename, err)
}
return nil
}
func saveJSON(filename string, value interface{}) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("encode configuration: %w", err)
}
data = append(data, '\n')
if err := os.WriteFile(filename, data, 0644); err != nil {
return fmt.Errorf("write configuration %q: %w", filename, err)
}
return nil
}

View file

@ -0,0 +1,459 @@
/*
Package config_test contains tests for the rs2322tcp configuration package.
Project: rs2322tcp
Module: git.lang-dieter.de/rs2322tcp
*/
package config_test
import (
"encoding/json"
"git.lang-dieter.de/rs2322tcp/internal/config"
"os"
"path/filepath"
"strings"
"testing"
)
///////////////////////////////////////////////////////////////////////////////
// Server configuration
///////////////////////////////////////////////////////////////////////////////
func TestLoadServer(t *testing.T) {
dir := t.TempDir()
filename := filepath.Join(dir, "server.json")
data := `{
"listen": {
"address": "0.0.0.0",
"port": 5000
},
"devices": [
{
"id": "radio",
"name": "Funkgerät",
"serial_port": "/dev/ttyUSB0",
"baud_rate": 9600,
"data_bits": 8,
"parity": "none",
"stop_bits": 1
}
]
}`
if err := os.WriteFile(filename, []byte(data), 0644); err != nil {
t.Fatalf("write test configuration: %v", err)
}
cfg, err := config.LoadServer(filename)
if err != nil {
t.Fatalf("LoadServer() failed: %v", err)
}
if cfg.Listen.Address != "0.0.0.0" {
t.Errorf("Listen.Address = %q, want %q",
cfg.Listen.Address, "0.0.0.0")
}
if cfg.Listen.Port != 5000 {
t.Errorf("Listen.Port = %d, want %d",
cfg.Listen.Port, 5000)
}
if len(cfg.Devices) != 1 {
t.Fatalf("len(Devices) = %d, want 1", len(cfg.Devices))
}
device := cfg.Devices[0]
if device.ID != "radio" {
t.Errorf("Device.ID = %q, want %q",
device.ID, "radio")
}
if device.SerialPort != "/dev/ttyUSB0" {
t.Errorf("Device.SerialPort = %q, want %q",
device.SerialPort, "/dev/ttyUSB0")
}
if device.BaudRate != 9600 {
t.Errorf("Device.BaudRate = %d, want %d",
device.BaudRate, 9600)
}
}
///////////////////////////////////////////////////////////////////////////////
// Client configuration
///////////////////////////////////////////////////////////////////////////////
func TestLoadClient(t *testing.T) {
dir := t.TempDir()
filename := filepath.Join(dir, "client.json")
data := `{
"server": {
"address": "100.64.0.10",
"port": 5000
},
"virtual_ports": [
{
"port": "COM7",
"remote_device": "radio"
}
]
}`
if err := os.WriteFile(filename, []byte(data), 0644); err != nil {
t.Fatalf("write test configuration: %v", err)
}
cfg, err := config.LoadClient(filename)
if err != nil {
t.Fatalf("LoadClient() failed: %v", err)
}
if cfg.Server.Address != "100.64.0.10" {
t.Errorf("Server.Address = %q, want %q",
cfg.Server.Address, "100.64.0.10")
}
if cfg.Server.Port != 5000 {
t.Errorf("Server.Port = %d, want %d",
cfg.Server.Port, 5000)
}
if len(cfg.VirtualPorts) != 1 {
t.Fatalf("len(VirtualPorts) = %d, want 1",
len(cfg.VirtualPorts))
}
virtualPort := cfg.VirtualPorts[0]
if virtualPort.Port != "COM7" {
t.Errorf("VirtualPort.Port = %q, want %q",
virtualPort.Port, "COM7")
}
if virtualPort.RemoteDevice != "radio" {
t.Errorf("VirtualPort.RemoteDevice = %q, want %q",
virtualPort.RemoteDevice, "radio")
}
}
///////////////////////////////////////////////////////////////////////////////
// Save and reload
///////////////////////////////////////////////////////////////////////////////
func TestSaveAndLoadClient(t *testing.T) {
dir := t.TempDir()
filename := filepath.Join(dir, "client.json")
original := &config.ClientConfig{
Server: config.ServerConnectionConfig{
Address: "100.64.0.10",
Port: 5000,
},
VirtualPorts: []config.VirtualPortConfig{
{
Port: "COM7",
RemoteDevice: "radio",
},
{
Port: "COM8",
RemoteDevice: "rotor",
},
},
}
if err := config.SaveClient(filename, original); err != nil {
t.Fatalf("SaveClient() failed: %v", err)
}
loaded, err := config.LoadClient(filename)
if err != nil {
t.Fatalf("LoadClient() failed: %v", err)
}
if loaded.Server != original.Server {
t.Errorf("loaded Server differs from original")
}
if len(loaded.VirtualPorts) != len(original.VirtualPorts) {
t.Fatalf("len(VirtualPorts) = %d, want %d",
len(loaded.VirtualPorts),
len(original.VirtualPorts))
}
for i := range original.VirtualPorts {
if loaded.VirtualPorts[i] != original.VirtualPorts[i] {
t.Errorf("VirtualPorts[%d] differs from original", i)
}
}
}
///////////////////////////////////////////////////////////////////////////////
// Validation
///////////////////////////////////////////////////////////////////////////////
func TestServerValidationDuplicateDeviceID(t *testing.T) {
cfg := &config.ServerConfig{
Listen: config.ListenConfig{
Port: 5000,
},
Devices: []config.DeviceConfig{
{
ID: "radio",
SerialPort: "/dev/ttyUSB0",
BaudRate: 9600,
DataBits: 8,
StopBits: 1,
},
{
ID: "radio",
SerialPort: "/dev/ttyUSB1",
BaudRate: 4800,
DataBits: 8,
StopBits: 1,
},
},
}
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() succeeded, want duplicate ID error")
}
}
func TestClientValidationDuplicateVirtualPort(t *testing.T) {
cfg := &config.ClientConfig{
Server: config.ServerConnectionConfig{
Port: 5000,
},
VirtualPorts: []config.VirtualPortConfig{
{
Port: "COM7",
RemoteDevice: "radio",
},
{
Port: "COM7",
RemoteDevice: "rotor",
},
},
}
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() succeeded, want duplicate virtual port error")
}
}
func TestServerValidationInvalidPort(t *testing.T) {
cfg := &config.ServerConfig{
Listen: config.ListenConfig{
Port: 70000,
},
}
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() succeeded, want invalid port error")
}
}
func TestClientValidationInvalidPort(t *testing.T) {
cfg := &config.ClientConfig{
Server: config.ServerConnectionConfig{
Port: 0,
},
}
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() succeeded, want invalid port error")
}
}
///////////////////////////////////////////////////////////////////////////////
// Example configurations
///////////////////////////////////////////////////////////////////////////////
func TestExampleServerConfig(t *testing.T) {
cfg, err := config.LoadServer("../../configs/server.json")
if err != nil {
t.Fatalf("LoadServer() failed: %v", err)
}
if len(cfg.Devices) != 2 {
t.Fatalf("len(Devices) = %d, want 2", len(cfg.Devices))
}
}
func TestExampleClientConfig(t *testing.T) {
cfg, err := config.LoadClient("../../configs/client.json")
if err != nil {
t.Fatalf("LoadClient() failed: %v", err)
}
if len(cfg.VirtualPorts) != 2 {
t.Fatalf("len(VirtualPorts) = %d, want 2", len(cfg.VirtualPorts))
}
}
///////////////////////////////////////////////////////////////////////////////
// Remote device
///////////////////////////////////////////////////////////////////////////////
func TestDeviceRemoteDevice(t *testing.T) {
device := config.DeviceConfig{
ID: "radio",
Name: "Funkgerät",
SerialPort: "/dev/ttyUSB0",
BaudRate: 9600,
DataBits: 8,
Parity: "none",
StopBits: 1,
}
remote := device.RemoteDevice()
if remote.ID != "radio" {
t.Errorf("RemoteDevice.ID = %q, want %q",
remote.ID, "radio")
}
if remote.Name != "Funkgerät" {
t.Errorf("RemoteDevice.Name = %q, want %q",
remote.Name, "Funkgerät")
}
if remote.BaudRate != 9600 {
t.Errorf("RemoteDevice.BaudRate = %d, want %d",
remote.BaudRate, 9600)
}
if remote.DataBits != 8 {
t.Errorf("RemoteDevice.DataBits = %d, want %d",
remote.DataBits, 8)
}
if remote.Parity != "none" {
t.Errorf("RemoteDevice.Parity = %q, want %q",
remote.Parity, "none")
}
if remote.StopBits != 1 {
t.Errorf("RemoteDevice.StopBits = %d, want %d",
remote.StopBits, 1)
}
}
///////////////////////////////////////////////////////////////////////////////
// Remote device list
///////////////////////////////////////////////////////////////////////////////
func TestServerRemoteDevices(t *testing.T) {
cfg := &config.ServerConfig{
Devices: []config.DeviceConfig{
{
ID: "radio",
Name: "Funkgerät",
SerialPort: "/dev/ttyUSB0",
BaudRate: 9600,
DataBits: 8,
Parity: "none",
StopBits: 1,
},
{
ID: "rotor",
Name: "Antennenrotor",
SerialPort: "/dev/ttyUSB1",
BaudRate: 4800,
DataBits: 8,
Parity: "none",
StopBits: 1,
},
},
}
list := cfg.RemoteDevices()
if len(list.Devices) != 2 {
t.Fatalf("len(Devices) = %d, want 2", len(list.Devices))
}
if list.Devices[0].ID != "radio" {
t.Errorf("Devices[0].ID = %q, want %q",
list.Devices[0].ID, "radio")
}
if list.Devices[1].ID != "rotor" {
t.Errorf("Devices[1].ID = %q, want %q",
list.Devices[1].ID, "rotor")
}
if list.Devices[0].Name != "Funkgerät" {
t.Errorf("Devices[0].Name = %q, want %q",
list.Devices[0].Name, "Funkgerät")
}
if list.Devices[0].BaudRate != 9600 {
t.Errorf("Devices[0].BaudRate = %d, want %d",
list.Devices[0].BaudRate, 9600)
}
if list.Devices[1].BaudRate != 4800 {
t.Errorf("Devices[1].BaudRate = %d, want %d",
list.Devices[1].BaudRate, 4800)
}
}
func TestServerRemoteDevicesNil(t *testing.T) {
var cfg *config.ServerConfig
list := cfg.RemoteDevices()
if len(list.Devices) != 0 {
t.Fatalf("len(Devices) = %d, want 0", len(list.Devices))
}
}
///////////////////////////////////////////////////////////////////////////////
// Remote device JSON
///////////////////////////////////////////////////////////////////////////////
func TestRemoteDeviceJSON(t *testing.T) {
list := config.DeviceList{
Devices: []config.RemoteDevice{
{
ID: "radio",
Name: "Funkgerät",
BaudRate: 9600,
DataBits: 8,
Parity: "none",
StopBits: 1,
},
},
}
data, err := json.Marshal(list)
if err != nil {
t.Fatalf("json.Marshal() failed: %v", err)
}
jsonText := string(data)
if strings.Contains(jsonText, "serial_port") {
t.Fatalf("JSON contains server-internal serial_port")
}
var decoded config.DeviceList
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("json.Unmarshal() failed: %v", err)
}
if len(decoded.Devices) != 1 {
t.Fatalf("len(Devices) = %d, want 1", len(decoded.Devices))
}
if decoded.Devices[0].ID != "radio" {
t.Errorf("Devices[0].ID = %q, want %q",
decoded.Devices[0].ID, "radio")
}
}

83
internal/config/device.go Normal file
View file

@ -0,0 +1,83 @@
/*
Package config provides configuration types and JSON handling for rs2322tcp.
This file contains the distinction between the server-internal physical
device configuration and the public device information made available
to clients.
Project: rs2322tcp
Module: git.lang-dieter.de/rs2322tcp
*/
package config
///////////////////////////////////////////////////////////////////////////////
// Remote device
///////////////////////////////////////////////////////////////////////////////
// RemoteDevice describes a physical RS232 device that is made available
// to a client by the rs2322tcp server.
//
// Server-internal information such as the physical serial device path
// is deliberately not included.
type RemoteDevice struct {
ID string `json:"id"`
Name string `json:"name"`
BaudRate int `json:"baud_rate"`
DataBits int `json:"data_bits"`
Parity string `json:"parity"`
StopBits int `json:"stop_bits"`
}
///////////////////////////////////////////////////////////////////////////////
// Remote device list
///////////////////////////////////////////////////////////////////////////////
// DeviceList contains the remote devices made available by the server.
type DeviceList struct {
Devices []RemoteDevice `json:"devices"`
}
///////////////////////////////////////////////////////////////////////////////
// Public device information
///////////////////////////////////////////////////////////////////////////////
// RemoteDevice returns the public description of a configured physical
// device.
//
// The physical serial port remains server-internal and is not exposed
// to the client.
func (cfg DeviceConfig) RemoteDevice() RemoteDevice {
return RemoteDevice{
ID: cfg.ID,
Name: cfg.Name,
BaudRate: cfg.BaudRate,
DataBits: cfg.DataBits,
Parity: cfg.Parity,
StopBits: cfg.StopBits,
}
}
///////////////////////////////////////////////////////////////////////////////
// Remote device list
///////////////////////////////////////////////////////////////////////////////
// RemoteDevices returns the public device list for all configured
// physical devices.
//
// Server-internal information such as the physical serial port is
// deliberately omitted.
func (cfg *ServerConfig) RemoteDevices() DeviceList {
if cfg == nil {
return DeviceList{}
}
devices := make([]RemoteDevice, 0, len(cfg.Devices))
for _, device := range cfg.Devices {
devices = append(devices, device.RemoteDevice())
}
return DeviceList{
Devices: devices,
}
}

159
internal/serial/serial.go Normal file
View file

@ -0,0 +1,159 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: serial.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Öffnet und verwaltet eine serielle Schnittstelle auf Basis der
* konfigurierten Geräteparameter.
* ============================================================================
*/
package serial
import (
"fmt"
"io"
bugserial "go.bug.st/serial"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
///////////////////////////////////////////////////////////////////////////////
// Connection
///////////////////////////////////////////////////////////////////////////////
// Connection represents an opened serial connection.
type Connection struct {
port bugserial.Port
}
///////////////////////////////////////////////////////////////////////////////
// Open
///////////////////////////////////////////////////////////////////////////////
// Open opens the serial device described by the configuration.
func Open(device config.DeviceConfig) (*Connection, error) {
mode, err := createMode(device)
if err != nil {
return nil, err
}
port, err := bugserial.Open(device.SerialPort, mode)
if err != nil {
return nil, fmt.Errorf(
"open serial port %q: %w",
device.SerialPort,
err,
)
}
return &Connection{
port: port,
}, nil
}
///////////////////////////////////////////////////////////////////////////////
// Mode
///////////////////////////////////////////////////////////////////////////////
func createMode(device config.DeviceConfig) (*bugserial.Mode, error) {
mode := &bugserial.Mode{
BaudRate: device.BaudRate,
DataBits: device.DataBits,
Parity: bugserial.NoParity,
StopBits: bugserial.OneStopBit,
}
if device.BaudRate <= 0 {
return nil, fmt.Errorf(
"invalid baud rate: %d",
device.BaudRate,
)
}
switch device.DataBits {
case 5, 6, 7, 8:
// Supported by go.bug.st/serial.
default:
return nil, fmt.Errorf(
"unsupported data bits: %d",
device.DataBits,
)
}
switch device.Parity {
case "none":
mode.Parity = bugserial.NoParity
case "odd":
mode.Parity = bugserial.OddParity
case "even":
mode.Parity = bugserial.EvenParity
default:
return nil, fmt.Errorf(
"unsupported parity: %q",
device.Parity,
)
}
switch device.StopBits {
case 1:
mode.StopBits = bugserial.OneStopBit
case 2:
mode.StopBits = bugserial.TwoStopBits
default:
return nil, fmt.Errorf(
"unsupported stop bits: %d",
device.StopBits,
)
}
return mode, nil
}
///////////////////////////////////////////////////////////////////////////////
// Read / Write
///////////////////////////////////////////////////////////////////////////////
// Read reads data from the serial connection.
func (c *Connection) Read(p []byte) (int, error) {
if c == nil || c.port == nil {
return 0, io.ErrClosedPipe
}
return c.port.Read(p)
}
// Write writes data to the serial connection.
func (c *Connection) Write(p []byte) (int, error) {
if c == nil || c.port == nil {
return 0, io.ErrClosedPipe
}
return c.port.Write(p)
}
///////////////////////////////////////////////////////////////////////////////
// Close
///////////////////////////////////////////////////////////////////////////////
// Close closes the serial connection.
func (c *Connection) Close() error {
if c == nil || c.port == nil {
return nil
}
err := c.port.Close()
c.port = nil
return err
}

View file

@ -0,0 +1,391 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: serial_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für die serielle Schnittstellenabstraktion.
* Zusätzlich wird unter Linux mit socat ein virtuelles serielles Portpaar
* erzeugt, um Read, Write und Close ohne reale RS232-Hardware zu testen.
* ============================================================================
*/
package serial
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
///////////////////////////////////////////////////////////////////////////////
// Test helpers
///////////////////////////////////////////////////////////////////////////////
func testDevice() config.DeviceConfig {
return config.DeviceConfig{
Name: "Testgerät",
SerialPort: "/dev/ttyTEST",
BaudRate: 9600,
DataBits: 8,
Parity: "none",
StopBits: 1,
}
}
///////////////////////////////////////////////////////////////////////////////
// Mode tests
///////////////////////////////////////////////////////////////////////////////
func TestCreateMode(t *testing.T) {
device := testDevice()
mode, err := createMode(device)
if err != nil {
t.Fatalf("createMode() failed: %v", err)
}
if mode.BaudRate != 9600 {
t.Fatalf("BaudRate = %d, want 9600", mode.BaudRate)
}
if mode.DataBits != 8 {
t.Fatalf("DataBits = %d, want 8", mode.DataBits)
}
}
func TestCreateModeParity(t *testing.T) {
tests := []struct {
name string
parity string
}{
{"none", "none"},
{"odd", "odd"},
{"even", "even"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
device := testDevice()
device.Parity = tt.parity
if _, err := createMode(device); err != nil {
t.Fatalf(
"createMode() with parity %q failed: %v",
tt.parity,
err,
)
}
})
}
}
func TestCreateModeStopBits(t *testing.T) {
for _, stopBits := range []int{1, 2} {
t.Run(fmt.Sprintf("stopbits_%d", stopBits), func(t *testing.T) {
device := testDevice()
device.StopBits = stopBits
if _, err := createMode(device); err != nil {
t.Fatalf(
"createMode() with stop bits %d failed: %v",
stopBits,
err,
)
}
})
}
}
///////////////////////////////////////////////////////////////////////////////
// Invalid configuration
///////////////////////////////////////////////////////////////////////////////
func TestCreateModeRejectsInvalidParity(t *testing.T) {
device := testDevice()
device.Parity = "invalid"
if _, err := createMode(device); err == nil {
t.Fatal("createMode() succeeded with invalid parity")
}
}
func TestCreateModeRejectsInvalidStopBits(t *testing.T) {
device := testDevice()
device.StopBits = 3
if _, err := createMode(device); err == nil {
t.Fatal("createMode() succeeded with invalid stop bits")
}
}
func TestCreateModeRejectsInvalidDataBits(t *testing.T) {
device := testDevice()
device.DataBits = 9
if _, err := createMode(device); err == nil {
t.Fatal("createMode() succeeded with invalid data bits")
}
}
func TestCreateModeRejectsInvalidBaudRate(t *testing.T) {
device := testDevice()
device.BaudRate = 0
if _, err := createMode(device); err == nil {
t.Fatal("createMode() succeeded with invalid baud rate")
}
}
///////////////////////////////////////////////////////////////////////////////
// Open
///////////////////////////////////////////////////////////////////////////////
func TestOpenInvalidPort(t *testing.T) {
device := testDevice()
_, err := Open(device)
if err == nil {
t.Fatal("Open() succeeded with nonexistent serial port")
}
}
///////////////////////////////////////////////////////////////////////////////
// Nil connection
///////////////////////////////////////////////////////////////////////////////
func TestNilConnectionRead(t *testing.T) {
var connection *Connection
buffer := make([]byte, 1)
_, err := connection.Read(buffer)
if err == nil {
t.Fatal("Read() succeeded on nil connection")
}
}
func TestNilConnectionWrite(t *testing.T) {
var connection *Connection
_, err := connection.Write([]byte("test"))
if err == nil {
t.Fatal("Write() succeeded on nil connection")
}
}
func TestNilConnectionClose(t *testing.T) {
var connection *Connection
if err := connection.Close(); err != nil {
t.Fatalf("Close() failed: %v", err)
}
}
///////////////////////////////////////////////////////////////////////////////
// Virtual serial port
///////////////////////////////////////////////////////////////////////////////
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("failed to start socat: %v", err)
}
cleanup := func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
}
}
deadline := time.Now().Add(2 * time.Second)
for {
if _, errA := os.Stat(portA); errA == nil {
if _, errB := os.Stat(portB); errB == nil {
break
}
}
if time.Now().After(deadline) {
cleanup()
t.Fatal("timeout waiting for virtual serial ports")
}
time.Sleep(10 * time.Millisecond)
}
return portA, portB, cleanup
}
///////////////////////////////////////////////////////////////////////////////
// Read / Write integration test
///////////////////////////////////////////////////////////////////////////////
func TestVirtualSerialReadWrite(t *testing.T) {
portA, portB, cleanup := startVirtualSerialPair(t)
defer cleanup()
device := testDevice()
device.SerialPort = portA
connection, err := Open(device)
if err != nil {
t.Fatalf("Open() failed: %v", err)
}
defer connection.Close()
peer, err := os.OpenFile(
portB,
os.O_RDWR,
0,
)
if err != nil {
t.Fatalf("opening peer port failed: %v", err)
}
defer peer.Close()
////////////////////////////////////////////////////////////////////////////
// Serial -> peer
////////////////////////////////////////////////////////////////////////////
outgoing := []byte("hello from serial")
n, err := connection.Write(outgoing)
if err != nil {
t.Fatalf("serial Write() failed: %v", err)
}
if n != len(outgoing) {
t.Fatalf(
"serial Write() wrote %d bytes, want %d",
n,
len(outgoing),
)
}
received := make([]byte, len(outgoing))
if err := readWithTimeout(
peer,
received,
2*time.Second,
); err != nil {
t.Fatalf("peer Read() failed: %v", err)
}
if string(received) != string(outgoing) {
t.Fatalf(
"peer received %q, want %q",
string(received),
string(outgoing),
)
}
////////////////////////////////////////////////////////////////////////////
// peer -> Serial
////////////////////////////////////////////////////////////////////////////
incoming := []byte("hello from peer")
n, err = peer.Write(incoming)
if err != nil {
t.Fatalf("peer Write() failed: %v", err)
}
if n != len(incoming) {
t.Fatalf(
"peer Write() wrote %d bytes, want %d",
n,
len(incoming),
)
}
received = make([]byte, len(incoming))
if err := readWithTimeout(
connection,
received,
2*time.Second,
); err != nil {
t.Fatalf("serial Read() failed: %v", err)
}
if string(received) != string(incoming) {
t.Fatalf(
"serial received %q, want %q",
string(received),
string(incoming),
)
}
}
///////////////////////////////////////////////////////////////////////////////
// Timeout helper
///////////////////////////////////////////////////////////////////////////////
type reader interface {
Read([]byte) (int, error)
}
func readWithTimeout(
r reader,
buffer []byte,
timeout time.Duration,
) error {
result := make(chan error, 1)
go func() {
n, err := r.Read(buffer)
if err != nil {
result <- err
return
}
if n != len(buffer) {
result <- fmt.Errorf(
"read %d bytes, want %d",
n,
len(buffer),
)
return
}
result <- nil
}()
select {
case err := <-result:
return err
case <-time.After(timeout):
return fmt.Errorf("read timeout after %s", timeout)
}
}

508
internal/server/control.go Normal file
View file

@ -0,0 +1,508 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: control.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* TCP-Control-Server für rs2322tcp.
* Verwaltung der Control-Verbindungen, Client-Sessions und Bereitstellung
* der aktuellen Geräteinformationen einschließlich dynamischer Data-Ports.
* Verwaltung der dynamischen Datenverbindungen zwischen TCP und RS232.
* ============================================================================
*/
package server
import (
"bufio"
"errors"
"fmt"
"io"
"log"
"net"
"sync/atomic"
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/serial"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
///////////////////////////////////////////////////////////////////////////////
// ControlServer
///////////////////////////////////////////////////////////////////////////////
// ControlServer implements the rs2322tcp control listener.
type ControlServer struct {
config *config.ServerConfig
listener net.Listener
nextSessionID uint64
}
///////////////////////////////////////////////////////////////////////////////
// Constructor
///////////////////////////////////////////////////////////////////////////////
// NewControlServer creates a new control server using the supplied
// server configuration.
func NewControlServer(cfg *config.ServerConfig) (*ControlServer, error) {
if cfg == nil {
return nil, fmt.Errorf("server configuration is nil")
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return &ControlServer{
config: cfg,
}, nil
}
///////////////////////////////////////////////////////////////////////////////
// Listener
///////////////////////////////////////////////////////////////////////////////
// Listen starts the TCP control listener.
func (s *ControlServer) Listen() error {
if s == nil {
return fmt.Errorf("control server is nil")
}
if s.listener != nil {
return fmt.Errorf("control server is already listening")
}
address := net.JoinHostPort(
s.config.Listen.Address,
fmt.Sprintf("%d", s.config.Listen.Port),
)
listener, err := net.Listen("tcp", address)
if err != nil {
return fmt.Errorf("listen on %s: %w", address, err)
}
s.listener = listener
return nil
}
// Addr returns the actual address of the control listener.
func (s *ControlServer) Addr() net.Addr {
if s == nil || s.listener == nil {
return nil
}
return s.listener.Addr()
}
// Close stops the control listener.
func (s *ControlServer) Close() error {
if s == nil || s.listener == nil {
return nil
}
err := s.listener.Close()
s.listener = nil
return err
}
///////////////////////////////////////////////////////////////////////////////
// Serve
///////////////////////////////////////////////////////////////////////////////
// Serve accepts incoming control connections.
//
// Each connection is handled independently.
func (s *ControlServer) Serve() error {
if s == nil {
return fmt.Errorf("control server is nil")
}
if s.listener == nil {
return fmt.Errorf("control server is not listening")
}
for {
conn, err := s.listener.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return nil
}
return fmt.Errorf("accept control connection: %w", err)
}
go s.handleConnection(conn)
}
}
///////////////////////////////////////////////////////////////////////////////
// Connection handling
///////////////////////////////////////////////////////////////////////////////
// handleConnection creates a session for one control connection and
// handles all control messages belonging to that session.
func (s *ControlServer) handleConnection(conn net.Conn) *Session {
session, err := s.newSession(conn)
if err != nil {
log.Printf("create session: %v", err)
_ = conn.Close()
return nil
}
log.Printf("session %d connected", session.ID())
defer func() {
if err := session.Close(); err != nil {
log.Printf(
"session %d close error: %v",
session.ID(),
err,
)
}
log.Printf("session %d closed", session.ID())
}()
reader := bufio.NewReader(conn)
for {
var message transport.Message
if err := transport.ReadMessage(reader, &message); err != nil {
if !errors.Is(err, io.EOF) {
log.Printf(
"session %d control connection error: %v",
session.ID(),
err,
)
}
return session
}
if err := s.handleMessage(session, message); err != nil {
log.Printf(
"session %d control message error: %v",
session.ID(),
err,
)
_ = transport.WriteMessage(
conn,
transport.NewError(
transport.ErrorInternal,
err.Error(),
),
)
return session
}
}
}
// HandleConnectionForTest handles one control connection and returns
// the session after the connection has ended.
//
// This method exists only to allow package-level tests without opening
// a real TCP control listener.
func (s *ControlServer) HandleConnectionForTest(conn net.Conn) *Session {
return s.handleConnection(conn)
}
///////////////////////////////////////////////////////////////////////////////
// Session creation
///////////////////////////////////////////////////////////////////////////////
// newSession creates a new client session, assigns a unique ID and
// creates one dynamic data listener for every configured device.
//
// A data worker is started for every listener. The worker waits for an
// incoming TCP data connection and opens the corresponding serial device
// only when a client actually connects to the dynamic data port.
func (s *ControlServer) newSession(conn net.Conn) (*Session, error) {
if conn == nil {
return nil, fmt.Errorf("connection is nil")
}
id := atomic.AddUint64(&s.nextSessionID, 1)
session, err := NewSession(id, conn)
if err != nil {
return nil, err
}
for _, device := range s.config.Devices {
listener, err := NewDataListener()
if err != nil {
_ = session.Close()
return nil, fmt.Errorf(
"create data listener for device %q: %w",
device.ID,
err,
)
}
if err := session.AddDataListener(
device.ID,
listener,
); err != nil {
_ = session.Close()
return nil, fmt.Errorf(
"add data listener for device %q: %w",
device.ID,
err,
)
}
go s.runDataListener(session, device, listener)
}
return session, nil
}
///////////////////////////////////////////////////////////////////////////////
// Data connection handling
///////////////////////////////////////////////////////////////////////////////
// runDataListener waits for incoming TCP data connections for one
// configured device.
//
// The listener remains active for the lifetime of the session. A serial
// connection is opened only after a TCP client actually connects.
//
// Only one active data connection is permitted for each device.
func (s *ControlServer) runDataListener(
session *Session,
device config.DeviceConfig,
listener *DataListener,
) {
if session == nil || listener == nil {
return
}
for {
tcpConn, err := listener.Accept()
if err != nil {
if session.IsClosed() {
return
}
log.Printf(
"session %d device %q data accept error: %v",
session.ID(),
device.ID,
err,
)
return
}
if session.IsClosed() {
_ = tcpConn.Close()
return
}
if _, exists := session.DataConnection(device.ID); exists {
log.Printf(
"session %d device %q already has an active data connection",
session.ID(),
device.ID,
)
_ = tcpConn.Close()
continue
}
serialConn, err := serial.Open(device)
if err != nil {
log.Printf(
"session %d device %q serial open error: %v",
session.ID(),
device.ID,
err,
)
_ = tcpConn.Close()
continue
}
dataConnection, err := NewDataConnection(
tcpConn,
serialConn,
)
if err != nil {
log.Printf(
"session %d device %q create data connection error: %v",
session.ID(),
device.ID,
err,
)
_ = serialConn.Close()
_ = tcpConn.Close()
continue
}
if err := session.AddDataConnection(
device.ID,
dataConnection,
); err != nil {
log.Printf(
"session %d device %q register data connection error: %v",
session.ID(),
device.ID,
err,
)
continue
}
log.Printf(
"session %d device %q data connection established",
session.ID(),
device.ID,
)
err = dataConnection.Run()
if err != nil {
log.Printf(
"session %d device %q data connection error: %v",
session.ID(),
device.ID,
err,
)
}
if err := session.RemoveDataConnection(device.ID); err != nil {
log.Printf(
"session %d device %q remove data connection error: %v",
session.ID(),
device.ID,
err,
)
}
log.Printf(
"session %d device %q data connection closed",
session.ID(),
device.ID,
)
if session.IsClosed() {
return
}
}
}
///////////////////////////////////////////////////////////////////////////////
// Message handling
///////////////////////////////////////////////////////////////////////////////
// handleMessage processes one control message for the specified session.
func (s *ControlServer) handleMessage(
session *Session,
message transport.Message,
) error {
if session == nil {
return fmt.Errorf("session is nil")
}
conn := session.Conn()
if conn == nil {
return fmt.Errorf("session control connection is closed")
}
if message.Version != transport.ProtocolVersion {
return transport.WriteMessage(
conn,
transport.NewError(
transport.ErrorUnsupportedVersion,
fmt.Sprintf(
"unsupported protocol version: %d",
message.Version,
),
),
)
}
switch message.Type {
case transport.MessageHello:
return transport.WriteMessage(
conn,
transport.NewHelloResponse(),
)
case transport.MessageGetDevices:
return s.writeDeviceList(session)
default:
return transport.WriteMessage(
conn,
transport.NewError(
transport.ErrorUnknownMessage,
fmt.Sprintf(
"unknown message type: %q",
message.Type,
),
),
)
}
}
///////////////////////////////////////////////////////////////////////////////
// Device list
///////////////////////////////////////////////////////////////////////////////
// writeDeviceList sends the configured remote devices together with
// their session-specific dynamic data ports.
func (s *ControlServer) writeDeviceList(session *Session) error {
if session == nil {
return fmt.Errorf("session is nil")
}
conn := session.Conn()
if conn == nil {
return fmt.Errorf("session control connection is closed")
}
remoteDevices := s.config.RemoteDevices()
devices := make(
[]transport.RemoteDeviceInfo,
0,
len(remoteDevices.Devices),
)
for _, device := range remoteDevices.Devices {
dataPort := session.DataPort(device.ID)
if dataPort == 0 {
return fmt.Errorf(
"no data listener for device %q",
device.ID,
)
}
devices = append(
devices,
transport.NewRemoteDeviceInfo(
device,
dataPort,
),
)
}
return transport.WriteMessage(
conn,
transport.NewDeviceList(devices),
)
}

View file

@ -0,0 +1,635 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: control_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für den TCP-Control-Server einschließlich Session-Verwaltung,
* dynamischer Data-Ports und der bidirektionalen Verbindung zur
* seriellen Schnittstelle.
* ============================================================================
*/
package server_test
import (
"bufio"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/server"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
///////////////////////////////////////////////////////////////////////////////
// Test configuration
///////////////////////////////////////////////////////////////////////////////
func testServerConfig() *config.ServerConfig {
return &config.ServerConfig{
Listen: config.ListenConfig{
Address: "127.0.0.1",
Port: 5000,
},
Devices: []config.DeviceConfig{
{
ID: "radio",
Name: "Funkgerät",
SerialPort: "/dev/ttyUSB0",
BaudRate: 9600,
DataBits: 8,
Parity: "none",
StopBits: 1,
},
{
ID: "rotor",
Name: "Antennenrotor",
SerialPort: "/dev/ttyUSB1",
BaudRate: 4800,
DataBits: 8,
Parity: "none",
StopBits: 1,
},
},
}
}
///////////////////////////////////////////////////////////////////////////////
// Basic server tests
///////////////////////////////////////////////////////////////////////////////
func TestNewControlServer(t *testing.T) {
cfg := testServerConfig()
srv, err := server.NewControlServer(cfg)
if err != nil {
t.Fatalf("NewControlServer() failed: %v", err)
}
if srv == nil {
t.Fatal("NewControlServer() returned nil server")
}
}
func TestControlServerHello(t *testing.T) {
cfg := testServerConfig()
srv, err := server.NewControlServer(cfg)
if err != nil {
t.Fatalf("NewControlServer() failed: %v", err)
}
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
done := make(chan struct{})
var session *server.Session
go func() {
session = srv.HandleConnectionForTest(serverConn)
close(done)
}()
if err := transport.WriteMessage(
clientConn,
transport.NewHello(),
); err != nil {
t.Fatalf("WriteMessage() failed: %v", err)
}
reader := bufio.NewReader(clientConn)
var response transport.HelloResponseMessage
if err := transport.ReadMessage(reader, &response); err != nil {
t.Fatalf("ReadMessage() failed: %v", err)
}
if response.Type != transport.MessageHelloResponse {
t.Errorf(
"Type = %q, want %q",
response.Type,
transport.MessageHelloResponse,
)
}
clientConn.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("server connection handler did not terminate")
}
if session == nil {
t.Fatal("server returned nil session")
}
if session.ID() != 1 {
t.Errorf("Session ID = %d, want 1", session.ID())
}
if !session.IsClosed() {
t.Fatal("session is not closed")
}
}
func TestControlServerGetDevices(t *testing.T) {
cfg := testServerConfig()
srv, err := server.NewControlServer(cfg)
if err != nil {
t.Fatalf("NewControlServer() failed: %v", err)
}
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
done := make(chan struct{})
go func() {
srv.HandleConnectionForTest(serverConn)
close(done)
}()
if err := transport.WriteMessage(
clientConn,
transport.NewGetDevices(),
); err != nil {
t.Fatalf("WriteMessage() failed: %v", err)
}
reader := bufio.NewReader(clientConn)
var response transport.DeviceListMessage
if err := transport.ReadMessage(reader, &response); err != nil {
t.Fatalf("ReadMessage() failed: %v", err)
}
if response.Type != transport.MessageDeviceList {
t.Errorf(
"Type = %q, want %q",
response.Type,
transport.MessageDeviceList,
)
}
if len(response.Devices) != 2 {
t.Fatalf(
"len(Devices) = %d, want 2",
len(response.Devices),
)
}
if response.Devices[0].ID != "radio" {
t.Errorf(
"Devices[0].ID = %q, want %q",
response.Devices[0].ID,
"radio",
)
}
if response.Devices[1].ID != "rotor" {
t.Errorf(
"Devices[1].ID = %q, want %q",
response.Devices[1].ID,
"rotor",
)
}
if response.Devices[0].DataPort == 0 {
t.Fatal("radio data port is zero")
}
if response.Devices[1].DataPort == 0 {
t.Fatal("rotor data port is zero")
}
clientConn.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("server connection handler did not terminate")
}
}
func TestControlServerReconnectCreatesNewSession(t *testing.T) {
cfg := testServerConfig()
srv, err := server.NewControlServer(cfg)
if err != nil {
t.Fatalf("NewControlServer() failed: %v", err)
}
// First connection.
serverConn1, clientConn1 := net.Pipe()
done1 := make(chan *server.Session)
go func() {
done1 <- srv.HandleConnectionForTest(serverConn1)
}()
if err := transport.WriteMessage(
clientConn1,
transport.NewHello(),
); err != nil {
t.Fatalf("first WriteMessage() failed: %v", err)
}
reader1 := bufio.NewReader(clientConn1)
var response1 transport.HelloResponseMessage
if err := transport.ReadMessage(reader1, &response1); err != nil {
t.Fatalf("first ReadMessage() failed: %v", err)
}
if response1.Type != transport.MessageHelloResponse {
t.Errorf(
"first response Type = %q, want %q",
response1.Type,
transport.MessageHelloResponse,
)
}
if err := clientConn1.Close(); err != nil {
t.Fatalf("close first client connection: %v", err)
}
session1 := <-done1
if session1 == nil {
t.Fatal("first session is nil")
}
if session1.ID() != 1 {
t.Errorf(
"first session ID = %d, want 1",
session1.ID(),
)
}
if !session1.IsClosed() {
t.Fatal("first session is not closed")
}
// Second connection - simulates reconnect.
serverConn2, clientConn2 := net.Pipe()
done2 := make(chan *server.Session)
go func() {
done2 <- srv.HandleConnectionForTest(serverConn2)
}()
if err := transport.WriteMessage(
clientConn2,
transport.NewHello(),
); err != nil {
t.Fatalf("second WriteMessage() failed: %v", err)
}
reader2 := bufio.NewReader(clientConn2)
var response2 transport.HelloResponseMessage
if err := transport.ReadMessage(reader2, &response2); err != nil {
t.Fatalf("second ReadMessage() failed: %v", err)
}
if response2.Type != transport.MessageHelloResponse {
t.Errorf(
"second response Type = %q, want %q",
response2.Type,
transport.MessageHelloResponse,
)
}
if err := clientConn2.Close(); err != nil {
t.Fatalf("close second client connection: %v", err)
}
session2 := <-done2
if session2 == nil {
t.Fatal("second session is nil")
}
if session2.ID() != 2 {
t.Errorf(
"second session ID = %d, want 2",
session2.ID(),
)
}
if session2.ID() == session1.ID() {
t.Fatal("reconnect reused the old session ID")
}
if !session2.IsClosed() {
t.Fatal("second session is not closed")
}
}
///////////////////////////////////////////////////////////////////////////////
// Virtual serial pair
///////////////////////////////////////////////////////////////////////////////
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("failed to 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 {
break
}
if time.Now().After(deadline) {
cleanup()
t.Fatal("timeout waiting for virtual serial ports")
}
time.Sleep(10 * time.Millisecond)
}
return portA, portB, cleanup
}
///////////////////////////////////////////////////////////////////////////////
// Read helper
///////////////////////////////////////////////////////////////////////////////
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)
}
}
///////////////////////////////////////////////////////////////////////////////
// Data connection integration test
///////////////////////////////////////////////////////////////////////////////
func TestControlServerDataConnection(t *testing.T) {
portA, portB, cleanup := startVirtualSerialPair(t)
defer cleanup()
cfg := &config.ServerConfig{
Listen: config.ListenConfig{
Address: "127.0.0.1",
Port: 5000,
},
Devices: []config.DeviceConfig{
{
ID: "radio",
Name: "Funkgerät",
SerialPort: portA,
BaudRate: 9600,
DataBits: 8,
Parity: "none",
StopBits: 1,
},
},
}
srv, err := server.NewControlServer(cfg)
if err != nil {
t.Fatalf("NewControlServer() failed: %v", err)
}
////////////////////////////////////////////////////////////////////////////
// Control connection
////////////////////////////////////////////////////////////////////////////
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
done := make(chan *server.Session)
go func() {
done <- srv.HandleConnectionForTest(serverConn)
}()
if err := transport.WriteMessage(
clientConn,
transport.NewGetDevices(),
); err != nil {
t.Fatalf("WriteMessage() failed: %v", err)
}
controlReader := bufio.NewReader(clientConn)
var deviceList transport.DeviceListMessage
if err := transport.ReadMessage(
controlReader,
&deviceList,
); err != nil {
t.Fatalf("ReadMessage() failed: %v", err)
}
if len(deviceList.Devices) != 1 {
t.Fatalf(
"len(Devices) = %d, want 1",
len(deviceList.Devices),
)
}
if deviceList.Devices[0].ID != "radio" {
t.Fatalf(
"device ID = %q, want %q",
deviceList.Devices[0].ID,
"radio",
)
}
dataPort := deviceList.Devices[0].DataPort
if dataPort == 0 {
t.Fatal("data port is zero")
}
////////////////////////////////////////////////////////////////////////////
// Data connection
////////////////////////////////////////////////////////////////////////////
dataConn, err := net.DialTimeout(
"tcp",
net.JoinHostPort("127.0.0.1", fmt.Sprintf("%d", dataPort)),
time.Second,
)
if err != nil {
t.Fatalf("connect data port failed: %v", err)
}
defer dataConn.Close()
////////////////////////////////////////////////////////////////////////////
// Serial peer
////////////////////////////////////////////////////////////////////////////
serialPeer, err := os.OpenFile(
portB,
os.O_RDWR,
0,
)
if err != nil {
t.Fatalf("open serial peer failed: %v", err)
}
defer serialPeer.Close()
////////////////////////////////////////////////////////////////////////////
// TCP -> Serial
////////////////////////////////////////////////////////////////////////////
tcpToSerial := []byte("hello from tcp")
if _, err := dataConn.Write(tcpToSerial); err != nil {
t.Fatalf("TCP Write() failed: %v", err)
}
serialReceived := make([]byte, len(tcpToSerial))
readExactWithTimeout(
t,
serialPeer,
serialReceived,
2*time.Second,
)
if string(serialReceived) != string(tcpToSerial) {
t.Fatalf(
"serial received %q, want %q",
string(serialReceived),
string(tcpToSerial),
)
}
////////////////////////////////////////////////////////////////////////////
// Serial -> TCP
////////////////////////////////////////////////////////////////////////////
serialToTCP := []byte("hello from serial")
if _, err := serialPeer.Write(serialToTCP); err != nil {
t.Fatalf("serial Write() failed: %v", err)
}
tcpReceived := make([]byte, len(serialToTCP))
readExactWithTimeout(
t,
dataConn,
tcpReceived,
2*time.Second,
)
if string(tcpReceived) != string(serialToTCP) {
t.Fatalf(
"TCP received %q, want %q",
string(tcpReceived),
string(serialToTCP),
)
}
////////////////////////////////////////////////////////////////////////////
// Close data connection
////////////////////////////////////////////////////////////////////////////
if err := dataConn.Close(); err != nil {
t.Fatalf("close data connection failed: %v", err)
}
////////////////////////////////////////////////////////////////////////////
// Close control connection
////////////////////////////////////////////////////////////////////////////
if err := clientConn.Close(); err != nil {
t.Fatalf("close control connection failed: %v", err)
}
select {
case session := <-done:
if session == nil {
t.Fatal("server returned nil session")
}
if !session.IsClosed() {
t.Fatal("session is not closed")
}
case <-time.After(2 * time.Second):
t.Fatal("server connection handler did not terminate")
}
}

View file

@ -0,0 +1,169 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: data_connection.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Bidirektionale Datenverbindung zwischen einer TCP-Verbindung und
* einem seriellen Gerätekanal.
* ============================================================================
*/
package server
import (
"fmt"
"io"
"net"
"sync"
)
///////////////////////////////////////////////////////////////////////////////
// DataConnection
///////////////////////////////////////////////////////////////////////////////
// DataConnection connects one TCP connection with one serial device.
//
// Data is transferred bidirectionally:
//
// TCP -> Serial
// TCP <- Serial
//
// The serial side is represented by an io.ReadWriteCloser so that this
// server component does not depend directly on the concrete serial
// implementation.
type DataConnection struct {
tcp net.Conn
serial io.ReadWriteCloser
closeOnce sync.Once
closeErr error
}
///////////////////////////////////////////////////////////////////////////////
// Constructor
///////////////////////////////////////////////////////////////////////////////
// NewDataConnection creates a new bidirectional data connection.
func NewDataConnection(
tcp net.Conn,
serial io.ReadWriteCloser,
) (*DataConnection, error) {
if tcp == nil {
return nil, fmt.Errorf("TCP connection is nil")
}
if serial == nil {
return nil, fmt.Errorf("serial connection is nil")
}
return &DataConnection{
tcp: tcp,
serial: serial,
}, nil
}
///////////////////////////////////////////////////////////////////////////////
// Properties
///////////////////////////////////////////////////////////////////////////////
// TCPConn returns the TCP connection.
func (c *DataConnection) TCPConn() net.Conn {
if c == nil {
return nil
}
return c.tcp
}
// SerialConn returns the serial connection.
func (c *DataConnection) SerialConn() io.ReadWriteCloser {
if c == nil {
return nil
}
return c.serial
}
///////////////////////////////////////////////////////////////////////////////
// Run
///////////////////////////////////////////////////////////////////////////////
// Run transfers data in both directions.
//
// Run blocks until one of the two transfer directions terminates.
// The other direction is then stopped and both connections are closed.
//
// The first non-EOF transfer error is returned.
func (c *DataConnection) Run() error {
if c == nil {
return fmt.Errorf("data connection is nil")
}
var wg sync.WaitGroup
errCh := make(chan error, 2)
wg.Add(2)
go func() {
defer wg.Done()
_, err := io.Copy(c.serial, c.tcp)
errCh <- err
}()
go func() {
defer wg.Done()
_, err := io.Copy(c.tcp, c.serial)
errCh <- err
}()
err := <-errCh
_ = c.Close()
wg.Wait()
if err == nil || err == io.EOF {
return nil
}
return err
}
///////////////////////////////////////////////////////////////////////////////
// Lifecycle
///////////////////////////////////////////////////////////////////////////////
// Close closes both sides of the data connection.
//
// Close is safe to call multiple times.
func (c *DataConnection) Close() error {
if c == nil {
return nil
}
c.closeOnce.Do(func() {
var firstErr error
if c.tcp != nil {
if err := c.tcp.Close(); err != nil {
firstErr = err
}
}
if c.serial != nil {
if err := c.serial.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
c.closeErr = firstErr
})
return c.closeErr
}

View file

@ -0,0 +1,314 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: data_connection_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für die bidirektionale TCP-/RS232-Datenverbindung.
* ============================================================================
*/
package server_test
import (
"bytes"
"io"
"net"
"sync"
"testing"
"time"
"git.lang-dieter.de/rs2322tcp/internal/server"
)
///////////////////////////////////////////////////////////////////////////////
// Test serial connection
///////////////////////////////////////////////////////////////////////////////
type testSerialConnection struct {
reader *bytes.Reader
writer bytes.Buffer
mu sync.Mutex
closed bool
closeCh chan struct{}
closeOnce sync.Once
}
func newTestSerialConnection(data []byte) *testSerialConnection {
return &testSerialConnection{
reader: bytes.NewReader(data),
closeCh: make(chan struct{}),
}
}
func (s *testSerialConnection) Read(p []byte) (int, error) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return 0, io.ErrClosedPipe
}
n, err := s.reader.Read(p)
s.mu.Unlock()
if err != io.EOF {
return n, err
}
// A real serial connection normally waits for further data instead
// of immediately returning EOF. Block until the connection is closed.
<-s.closeCh
return 0, io.ErrClosedPipe
}
func (s *testSerialConnection) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return 0, io.ErrClosedPipe
}
return s.writer.Write(p)
}
func (s *testSerialConnection) Close() error {
s.closeOnce.Do(func() {
s.mu.Lock()
s.closed = true
s.mu.Unlock()
close(s.closeCh)
})
return nil
}
func (s *testSerialConnection) WrittenData() []byte {
s.mu.Lock()
defer s.mu.Unlock()
data := make([]byte, s.writer.Len())
copy(data, s.writer.Bytes())
return data
}
///////////////////////////////////////////////////////////////////////////////
// Constructor
///////////////////////////////////////////////////////////////////////////////
func TestNewDataConnection(t *testing.T) {
tcpServer, tcpClient := net.Pipe()
defer tcpServer.Close()
defer tcpClient.Close()
serial := newTestSerialConnection(nil)
connection, err := server.NewDataConnection(
tcpServer,
serial,
)
if err != nil {
t.Fatalf("NewDataConnection() failed: %v", err)
}
if connection.TCPConn() == nil {
t.Fatal("TCPConn() returned nil")
}
if connection.SerialConn() == nil {
t.Fatal("SerialConn() returned nil")
}
}
func TestNewDataConnectionRejectsNilTCP(t *testing.T) {
serial := newTestSerialConnection(nil)
connection, err := server.NewDataConnection(
nil,
serial,
)
if err == nil {
t.Fatal("NewDataConnection() succeeded, want error")
}
if connection != nil {
t.Fatal("NewDataConnection() returned connection despite error")
}
}
func TestNewDataConnectionRejectsNilSerial(t *testing.T) {
tcpServer, tcpClient := net.Pipe()
defer tcpServer.Close()
defer tcpClient.Close()
connection, err := server.NewDataConnection(
tcpServer,
nil,
)
if err == nil {
t.Fatal("NewDataConnection() succeeded, want error")
}
if connection != nil {
t.Fatal("NewDataConnection() returned connection despite error")
}
}
///////////////////////////////////////////////////////////////////////////////
// TCP -> Serial
///////////////////////////////////////////////////////////////////////////////
func TestDataConnectionTCPToSerial(t *testing.T) {
tcpServer, tcpClient := net.Pipe()
defer tcpClient.Close()
serial := newTestSerialConnection(nil)
connection, err := server.NewDataConnection(
tcpServer,
serial,
)
if err != nil {
t.Fatalf("NewDataConnection() failed: %v", err)
}
done := make(chan error, 1)
go func() {
done <- connection.Run()
}()
testData := []byte("hello serial")
if _, err := tcpClient.Write(testData); err != nil {
t.Fatalf("TCP Write() failed: %v", err)
}
deadline := time.Now().Add(time.Second)
for {
if bytes.Equal(serial.WrittenData(), testData) {
break
}
if time.Now().After(deadline) {
t.Fatalf(
"serial data = %q, want %q",
serial.WrittenData(),
testData,
)
}
time.Sleep(time.Millisecond)
}
_ = tcpClient.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("DataConnection.Run() did not terminate")
}
}
///////////////////////////////////////////////////////////////////////////////
// Serial -> TCP
///////////////////////////////////////////////////////////////////////////////
func TestDataConnectionSerialToTCP(t *testing.T) {
tcpServer, tcpClient := net.Pipe()
defer tcpClient.Close()
testData := []byte("hello tcp")
serial := newTestSerialConnection(testData)
connection, err := server.NewDataConnection(
tcpServer,
serial,
)
if err != nil {
t.Fatalf("NewDataConnection() failed: %v", err)
}
done := make(chan error, 1)
go func() {
done <- connection.Run()
}()
buffer := make([]byte, len(testData))
if _, err := io.ReadFull(tcpClient, buffer); err != nil {
t.Fatalf("TCP Read() failed: %v", err)
}
if !bytes.Equal(buffer, testData) {
t.Fatalf(
"TCP data = %q, want %q",
buffer,
testData,
)
}
_ = tcpClient.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("DataConnection.Run() did not terminate")
}
}
///////////////////////////////////////////////////////////////////////////////
// Close
///////////////////////////////////////////////////////////////////////////////
func TestDataConnectionClose(t *testing.T) {
tcpServer, tcpClient := net.Pipe()
defer tcpClient.Close()
serial := newTestSerialConnection(nil)
connection, err := server.NewDataConnection(
tcpServer,
serial,
)
if err != nil {
t.Fatalf("NewDataConnection() failed: %v", err)
}
if err := connection.Close(); err != nil {
t.Fatalf("Close() failed: %v", err)
}
if err := connection.Close(); err != nil {
t.Fatalf("second Close() failed: %v", err)
}
buffer := make([]byte, 1)
if _, err := tcpClient.Read(buffer); err == nil {
t.Fatal("TCP connection is still open")
}
serial.mu.Lock()
closed := serial.closed
serial.mu.Unlock()
if !closed {
t.Fatal("serial connection is still open")
}
}

View file

@ -0,0 +1,143 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: data_handler.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Verbindet eingehende TCP-Datenverbindungen eines Data-Listeners mit
* einem vom Server bereitgestellten seriellen Gerätekanal.
* ============================================================================
*/
package server
import (
"fmt"
"io"
"log"
"net"
)
///////////////////////////////////////////////////////////////////////////////
// SerialFactory
///////////////////////////////////////////////////////////////////////////////
// SerialFactory creates a serial connection for one device.
//
// The concrete implementation is provided by the serial package later.
// Keeping the factory as a function type prevents the server package from
// depending directly on the concrete serial implementation.
type SerialFactory func() (io.ReadWriteCloser, error)
///////////////////////////////////////////////////////////////////////////////
// DataHandler
///////////////////////////////////////////////////////////////////////////////
// DataHandler accepts TCP data connections and connects them to a serial
// device.
type DataHandler struct {
listener *DataListener
serialFactory SerialFactory
}
///////////////////////////////////////////////////////////////////////////////
// Constructor
///////////////////////////////////////////////////////////////////////////////
// NewDataHandler creates a new data handler.
//
// The listener provides the TCP endpoint. The serial factory is called for
// every accepted TCP connection.
func NewDataHandler(
listener *DataListener,
serialFactory SerialFactory,
) (*DataHandler, error) {
if listener == nil {
return nil, fmt.Errorf("data listener is nil")
}
if serialFactory == nil {
return nil, fmt.Errorf("serial factory is nil")
}
return &DataHandler{
listener: listener,
serialFactory: serialFactory,
}, nil
}
///////////////////////////////////////////////////////////////////////////////
// Serve
///////////////////////////////////////////////////////////////////////////////
// Serve waits for incoming TCP data connections.
//
// Every accepted connection gets its own serial connection and
// DataConnection. The handler runs independently for each client.
//
// Serve terminates when the listener is closed.
func (h *DataHandler) Serve() error {
if h == nil {
return fmt.Errorf("data handler is nil")
}
for {
tcpConn, err := h.listener.Accept()
if err != nil {
return err
}
go h.handleConnection(tcpConn)
}
}
///////////////////////////////////////////////////////////////////////////////
// Connection handling
///////////////////////////////////////////////////////////////////////////////
// handleConnection connects one accepted TCP connection to one serial
// connection.
func (h *DataHandler) handleConnection(tcpConn net.Conn) {
if tcpConn == nil {
return
}
serialConn, err := h.serialFactory()
if err != nil {
log.Printf("create serial connection: %v", err)
_ = tcpConn.Close()
return
}
dataConnection, err := NewDataConnection(
tcpConn,
serialConn,
)
if err != nil {
log.Printf("create data connection: %v", err)
_ = serialConn.Close()
_ = tcpConn.Close()
return
}
if err := dataConnection.Run(); err != nil {
log.Printf("data connection terminated: %v", err)
}
}
///////////////////////////////////////////////////////////////////////////////
// Test support
///////////////////////////////////////////////////////////////////////////////
// HandleConnectionForTest handles one TCP connection using the configured
// serial factory.
//
// This method is intentionally provided for package-level tests without
// requiring a real TCP listener.
func (h *DataHandler) HandleConnectionForTest(tcpConn net.Conn) {
h.handleConnection(tcpConn)
}

View file

@ -0,0 +1,350 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: data_handler_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für die Verbindung von DataListener, TCP-Datenverbindung und
* serieller Geräteverbindung.
* ============================================================================
*/
package server_test
import (
"bytes"
"io"
"net"
"sync"
"testing"
"time"
"git.lang-dieter.de/rs2322tcp/internal/server"
)
///////////////////////////////////////////////////////////////////////////////
// Test serial connection
///////////////////////////////////////////////////////////////////////////////
type handlerTestSerial struct {
reader *bytes.Reader
mu sync.Mutex
writer bytes.Buffer
closed bool
closeCh chan struct{}
closeOnce sync.Once
}
func newHandlerTestSerial(data []byte) *handlerTestSerial {
return &handlerTestSerial{
reader: bytes.NewReader(data),
closeCh: make(chan struct{}),
}
}
func (s *handlerTestSerial) Read(p []byte) (int, error) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return 0, io.ErrClosedPipe
}
n, err := s.reader.Read(p)
s.mu.Unlock()
if err != io.EOF {
return n, err
}
<-s.closeCh
return 0, io.ErrClosedPipe
}
func (s *handlerTestSerial) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return 0, io.ErrClosedPipe
}
return s.writer.Write(p)
}
func (s *handlerTestSerial) Close() error {
s.closeOnce.Do(func() {
s.mu.Lock()
s.closed = true
s.mu.Unlock()
close(s.closeCh)
})
return nil
}
func (s *handlerTestSerial) WrittenData() []byte {
s.mu.Lock()
defer s.mu.Unlock()
data := make([]byte, s.writer.Len())
copy(data, s.writer.Bytes())
return data
}
///////////////////////////////////////////////////////////////////////////////
// Constructor
///////////////////////////////////////////////////////////////////////////////
func TestNewDataHandler(t *testing.T) {
listener, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() failed: %v", err)
}
defer listener.Close()
serial := newHandlerTestSerial(nil)
handler, err := server.NewDataHandler(
listener,
func() (io.ReadWriteCloser, error) {
return serial, nil
},
)
if err != nil {
t.Fatalf("NewDataHandler() failed: %v", err)
}
if handler == nil {
t.Fatal("NewDataHandler() returned nil")
}
}
func TestNewDataHandlerRejectsNilListener(t *testing.T) {
handler, err := server.NewDataHandler(
nil,
func() (io.ReadWriteCloser, error) {
return newHandlerTestSerial(nil), nil
},
)
if err == nil {
t.Fatal("NewDataHandler() succeeded, want error")
}
if handler != nil {
t.Fatal("NewDataHandler() returned handler despite error")
}
}
func TestNewDataHandlerRejectsNilFactory(t *testing.T) {
listener, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() failed: %v", err)
}
defer listener.Close()
handler, err := server.NewDataHandler(
listener,
nil,
)
if err == nil {
t.Fatal("NewDataHandler() succeeded, want error")
}
if handler != nil {
t.Fatal("NewDataHandler() returned handler despite error")
}
}
///////////////////////////////////////////////////////////////////////////////
// TCP -> Serial
///////////////////////////////////////////////////////////////////////////////
func TestDataHandlerTCPToSerial(t *testing.T) {
listener, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() failed: %v", err)
}
defer listener.Close()
serial := newHandlerTestSerial(nil)
handler, err := server.NewDataHandler(
listener,
func() (io.ReadWriteCloser, error) {
return serial, nil
},
)
if err != nil {
t.Fatalf("NewDataHandler() failed: %v", err)
}
serveDone := make(chan error, 1)
go func() {
serveDone <- handler.Serve()
}()
tcpConn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatalf("net.Dial() failed: %v", err)
}
testData := []byte("handler tcp to serial")
if _, err := tcpConn.Write(testData); err != nil {
t.Fatalf("TCP Write() failed: %v", err)
}
deadline := time.Now().Add(time.Second)
for {
if bytes.Equal(serial.WrittenData(), testData) {
break
}
if time.Now().After(deadline) {
t.Fatalf(
"serial data = %q, want %q",
serial.WrittenData(),
testData,
)
}
time.Sleep(time.Millisecond)
}
_ = tcpConn.Close()
_ = listener.Close()
select {
case <-serveDone:
case <-time.After(time.Second):
t.Fatal("DataHandler.Serve() did not terminate")
}
}
///////////////////////////////////////////////////////////////////////////////
// Serial -> TCP
///////////////////////////////////////////////////////////////////////////////
func TestDataHandlerSerialToTCP(t *testing.T) {
listener, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() failed: %v", err)
}
defer listener.Close()
testData := []byte("handler serial to tcp")
serial := newHandlerTestSerial(testData)
handler, err := server.NewDataHandler(
listener,
func() (io.ReadWriteCloser, error) {
return serial, nil
},
)
if err != nil {
t.Fatalf("NewDataHandler() failed: %v", err)
}
serveDone := make(chan error, 1)
go func() {
serveDone <- handler.Serve()
}()
tcpConn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatalf("net.Dial() failed: %v", err)
}
buffer := make([]byte, len(testData))
if _, err := io.ReadFull(tcpConn, buffer); err != nil {
t.Fatalf("TCP Read() failed: %v", err)
}
if !bytes.Equal(buffer, testData) {
t.Fatalf(
"TCP data = %q, want %q",
buffer,
testData,
)
}
_ = tcpConn.Close()
_ = listener.Close()
select {
case <-serveDone:
case <-time.After(time.Second):
t.Fatal("DataHandler.Serve() did not terminate")
}
}
///////////////////////////////////////////////////////////////////////////////
// Serial factory error
///////////////////////////////////////////////////////////////////////////////
func TestDataHandlerSerialFactoryError(t *testing.T) {
listener, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() failed: %v", err)
}
defer listener.Close()
handler, err := server.NewDataHandler(
listener,
func() (io.ReadWriteCloser, error) {
return nil, io.ErrClosedPipe
},
)
if err != nil {
t.Fatalf("NewDataHandler() failed: %v", err)
}
serveDone := make(chan error, 1)
go func() {
serveDone <- handler.Serve()
}()
tcpConn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatalf("net.Dial() failed: %v", err)
}
// The handler must close the TCP connection when the serial factory
// fails.
buffer := make([]byte, 1)
_ = tcpConn.SetReadDeadline(time.Now().Add(time.Second))
_, readErr := tcpConn.Read(buffer)
if readErr == nil {
t.Fatal("TCP connection remained open after serial factory error")
}
_ = tcpConn.Close()
_ = listener.Close()
select {
case <-serveDone:
case <-time.After(time.Second):
t.Fatal("DataHandler.Serve() did not terminate")
}
}

View file

@ -0,0 +1,146 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: data_listener.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Dynamischer TCP-Listener für die RS232-Datenverbindungen.
* Der Listener verwendet einen vom Betriebssystem zugewiesenen freien Port.
* Der Zugriff auf den zugrunde liegenden Netzwerk-Listener ist
* nebenläufigkeitssicher.
* ============================================================================
*/
package server
import (
"fmt"
"net"
"sync"
)
///////////////////////////////////////////////////////////////////////////////
// DataListener
///////////////////////////////////////////////////////////////////////////////
// DataListener represents one TCP listener for an RS232 data connection.
//
// The listener uses a dynamically assigned TCP port. The actual port is
// available through Port().
type DataListener struct {
mu sync.Mutex
listener net.Listener
}
///////////////////////////////////////////////////////////////////////////////
// Constructor
///////////////////////////////////////////////////////////////////////////////
// NewDataListener creates a TCP listener on a dynamically assigned port.
//
// The listener is bound to all local interfaces.
func NewDataListener() (*DataListener, error) {
listener, err := net.Listen("tcp", ":0")
if err != nil {
return nil, fmt.Errorf("listen on dynamic data port: %w", err)
}
return &DataListener{
listener: listener,
}, nil
}
///////////////////////////////////////////////////////////////////////////////
// Properties
///////////////////////////////////////////////////////////////////////////////
// Addr returns the actual network address of the listener.
func (l *DataListener) Addr() net.Addr {
if l == nil {
return nil
}
l.mu.Lock()
defer l.mu.Unlock()
if l.listener == nil {
return nil
}
return l.listener.Addr()
}
// Port returns the dynamically assigned TCP port.
//
// It returns 0 if the listener is nil or has already been closed.
func (l *DataListener) Port() int {
if l == nil {
return 0
}
l.mu.Lock()
defer l.mu.Unlock()
if l.listener == nil {
return 0
}
tcpAddr, ok := l.listener.Addr().(*net.TCPAddr)
if !ok {
return 0
}
return tcpAddr.Port
}
///////////////////////////////////////////////////////////////////////////////
// Accept
///////////////////////////////////////////////////////////////////////////////
// Accept waits for an incoming data connection.
func (l *DataListener) Accept() (net.Conn, error) {
if l == nil {
return nil, fmt.Errorf("data listener is closed")
}
l.mu.Lock()
listener := l.listener
l.mu.Unlock()
if listener == nil {
return nil, fmt.Errorf("data listener is closed")
}
return listener.Accept()
}
///////////////////////////////////////////////////////////////////////////////
// Lifecycle
///////////////////////////////////////////////////////////////////////////////
// Close closes the data listener.
//
// Close is safe to call more than once and may safely run concurrently
// with Accept().
func (l *DataListener) Close() error {
if l == nil {
return nil
}
l.mu.Lock()
listener := l.listener
l.listener = nil
l.mu.Unlock()
if listener == nil {
return nil
}
return listener.Close()
}

View file

@ -0,0 +1,150 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: data_listener_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für den dynamischen TCP-Data-Listener.
* ============================================================================
*/
package server_test
import (
"fmt"
"net"
"testing"
"time"
"git.lang-dieter.de/rs2322tcp/internal/server"
)
func TestNewDataListener(t *testing.T) {
listener, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() failed: %v", err)
}
defer listener.Close()
if listener.Addr() == nil {
t.Fatal("Addr() returned nil")
}
if listener.Port() == 0 {
t.Fatal("Port() returned 0")
}
}
func TestDataListenerAccept(t *testing.T) {
listener, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() failed: %v", err)
}
defer listener.Close()
done := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if conn != nil {
_ = conn.Close()
}
done <- err
}()
conn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatalf("net.Dial() failed: %v", err)
}
defer conn.Close()
select {
case err := <-done:
if err != nil {
t.Fatalf("Accept() failed: %v", err)
}
case <-time.After(time.Second):
t.Fatal("Accept() did not return")
}
}
func TestDataListenerClose(t *testing.T) {
listener, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() failed: %v", err)
}
if listener.Port() == 0 {
t.Fatal("Port() returned 0")
}
if err := listener.Close(); err != nil {
t.Fatalf("Close() failed: %v", err)
}
if listener.Port() != 0 {
t.Errorf("Port() after Close() = %d, want 0",
listener.Port())
}
if err := listener.Close(); err != nil {
t.Fatalf("second Close() failed: %v", err)
}
}
func TestDataListenerGetsDifferentPorts(t *testing.T) {
listener1, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() #1 failed: %v", err)
}
defer listener1.Close()
listener2, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() #2 failed: %v", err)
}
defer listener2.Close()
port1 := listener1.Port()
port2 := listener2.Port()
if port1 == 0 {
t.Fatal("listener1 Port() returned 0")
}
if port2 == 0 {
t.Fatal("listener2 Port() returned 0")
}
if port1 == port2 {
t.Fatalf("both listeners use port %d", port1)
}
}
func TestDataListenerCloseReleasesPort(t *testing.T) {
listener, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() failed: %v", err)
}
port := listener.Port()
if err := listener.Close(); err != nil {
t.Fatalf("Close() failed: %v", err)
}
address := fmt.Sprintf("127.0.0.1:%d", port)
testListener, err := net.Listen("tcp", address)
if err != nil {
t.Fatalf("port %d was not released: %v", port, err)
}
if err := testListener.Close(); err != nil {
t.Fatalf("closing test listener failed: %v", err)
}
}

462
internal/server/session.go Normal file
View file

@ -0,0 +1,462 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: session.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Verwaltung des Lebenszyklus einer Client-Session einschließlich
* der zugehörigen Netzwerkressourcen, Data-Listener und aktiven
* Datenverbindungen.
* ============================================================================
*/
package server
import (
"fmt"
"io"
"net"
"sync"
)
///////////////////////////////////////////////////////////////////////////////
// Session
///////////////////////////////////////////////////////////////////////////////
// Session represents one active client connection.
//
// All resources belonging to one client connection are associated with
// the session. This includes the control connection, dynamic data
// listeners and active data connections belonging to the configured
// remote devices.
type Session struct {
id uint64
conn net.Conn
mu sync.Mutex
closed bool
resources []io.Closer
dataListeners map[string]*DataListener
dataConnections map[string]*DataConnection
}
///////////////////////////////////////////////////////////////////////////////
// Constructor
///////////////////////////////////////////////////////////////////////////////
// NewSession creates a new client session.
func NewSession(id uint64, conn net.Conn) (*Session, error) {
if conn == nil {
return nil, fmt.Errorf("connection is nil")
}
if id == 0 {
return nil, fmt.Errorf("session ID must not be zero")
}
return &Session{
id: id,
conn: conn,
resources: make([]io.Closer, 0),
dataListeners: make(map[string]*DataListener),
dataConnections: make(map[string]*DataConnection),
}, nil
}
///////////////////////////////////////////////////////////////////////////////
// Properties
///////////////////////////////////////////////////////////////////////////////
// ID returns the unique session ID.
func (s *Session) ID() uint64 {
if s == nil {
return 0
}
return s.id
}
// Conn returns the control connection belonging to the session.
func (s *Session) Conn() net.Conn {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil
}
return s.conn
}
// IsClosed reports whether the session has already been closed.
func (s *Session) IsClosed() bool {
if s == nil {
return true
}
s.mu.Lock()
defer s.mu.Unlock()
return s.closed
}
///////////////////////////////////////////////////////////////////////////////
// Resources
///////////////////////////////////////////////////////////////////////////////
// AddResource adds a resource to the session.
//
// The resource must implement io.Closer. It will automatically be closed
// when the session is closed.
//
// If the session is already closed, the resource is closed immediately
// and an error is returned.
func (s *Session) AddResource(resource io.Closer) error {
if s == nil {
if resource != nil {
_ = resource.Close()
}
return fmt.Errorf("session is nil")
}
if resource == nil {
return fmt.Errorf("resource is nil")
}
s.mu.Lock()
if s.closed {
s.mu.Unlock()
_ = resource.Close()
return fmt.Errorf("session is already closed")
}
s.resources = append(s.resources, resource)
s.mu.Unlock()
return nil
}
// ResourceCount returns the number of resources currently owned by the
// session.
//
// This method is primarily useful for diagnostics and tests.
func (s *Session) ResourceCount() int {
if s == nil {
return 0
}
s.mu.Lock()
defer s.mu.Unlock()
return len(s.resources)
}
///////////////////////////////////////////////////////////////////////////////
// Data listeners
///////////////////////////////////////////////////////////////////////////////
// AddDataListener associates a dynamic data listener with a remote device.
//
// The listener becomes a session resource and is therefore automatically
// closed when the session is closed.
func (s *Session) AddDataListener(
deviceID string,
listener *DataListener,
) error {
if s == nil {
if listener != nil {
_ = listener.Close()
}
return fmt.Errorf("session is nil")
}
if deviceID == "" {
if listener != nil {
_ = listener.Close()
}
return fmt.Errorf("device ID is empty")
}
if listener == nil {
return fmt.Errorf("data listener is nil")
}
s.mu.Lock()
if s.closed {
s.mu.Unlock()
_ = listener.Close()
return fmt.Errorf("session is already closed")
}
if _, exists := s.dataListeners[deviceID]; exists {
s.mu.Unlock()
_ = listener.Close()
return fmt.Errorf(
"data listener already exists for device %q",
deviceID,
)
}
s.resources = append(s.resources, listener)
s.dataListeners[deviceID] = listener
s.mu.Unlock()
return nil
}
// DataListener returns the data listener associated with a device.
//
// The second return value reports whether a listener exists for the
// specified device.
func (s *Session) DataListener(
deviceID string,
) (*DataListener, bool) {
if s == nil {
return nil, false
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil, false
}
listener, ok := s.dataListeners[deviceID]
return listener, ok
}
// DataPort returns the dynamic TCP data port associated with a device.
//
// It returns 0 if the device has no listener or the session is closed.
func (s *Session) DataPort(deviceID string) int {
listener, ok := s.DataListener(deviceID)
if !ok {
return 0
}
return listener.Port()
}
// DataListenerCount returns the number of data listeners currently
// associated with the session.
func (s *Session) DataListenerCount() int {
if s == nil {
return 0
}
s.mu.Lock()
defer s.mu.Unlock()
return len(s.dataListeners)
}
///////////////////////////////////////////////////////////////////////////////
// Active data connections
///////////////////////////////////////////////////////////////////////////////
// AddDataConnection associates an active data connection with a remote
// device.
//
// Only one active data connection is allowed per device. The data
// connection becomes a session resource and is therefore automatically
// closed when the session is closed.
func (s *Session) AddDataConnection(
deviceID string,
connection *DataConnection,
) error {
if s == nil {
if connection != nil {
_ = connection.Close()
}
return fmt.Errorf("session is nil")
}
if deviceID == "" {
if connection != nil {
_ = connection.Close()
}
return fmt.Errorf("device ID is empty")
}
if connection == nil {
return fmt.Errorf("data connection is nil")
}
s.mu.Lock()
if s.closed {
s.mu.Unlock()
_ = connection.Close()
return fmt.Errorf("session is already closed")
}
if _, exists := s.dataConnections[deviceID]; exists {
s.mu.Unlock()
_ = connection.Close()
return fmt.Errorf(
"data connection already exists for device %q",
deviceID,
)
}
s.resources = append(s.resources, connection)
s.dataConnections[deviceID] = connection
s.mu.Unlock()
return nil
}
// DataConnection returns the active data connection associated with a
// device.
//
// The second return value reports whether an active connection exists.
func (s *Session) DataConnection(
deviceID string,
) (*DataConnection, bool) {
if s == nil {
return nil, false
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil, false
}
connection, ok := s.dataConnections[deviceID]
return connection, ok
}
// RemoveDataConnection removes an active data connection from the
// session.
//
// The connection is closed before it is removed from the session.
// Removing a connection that is not registered is harmless.
func (s *Session) RemoveDataConnection(deviceID string) error {
if s == nil {
return nil
}
s.mu.Lock()
connection, exists := s.dataConnections[deviceID]
if !exists {
s.mu.Unlock()
return nil
}
delete(s.dataConnections, deviceID)
for i, resource := range s.resources {
if resource == connection {
s.resources = append(
s.resources[:i],
s.resources[i+1:]...,
)
break
}
}
s.mu.Unlock()
return connection.Close()
}
// DataConnectionCount returns the number of currently active data
// connections.
func (s *Session) DataConnectionCount() int {
if s == nil {
return 0
}
s.mu.Lock()
defer s.mu.Unlock()
return len(s.dataConnections)
}
///////////////////////////////////////////////////////////////////////////////
// Lifecycle
///////////////////////////////////////////////////////////////////////////////
// Close terminates the session and releases all resources belonging
// to the session.
//
// Close is safe to call multiple times.
//
// Data listeners and other session resources are closed before the
// control connection is closed.
func (s *Session) Close() error {
if s == nil {
return nil
}
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
conn := s.conn
s.conn = nil
resources := s.resources
s.resources = nil
s.dataListeners = nil
s.dataConnections = nil
s.mu.Unlock()
var firstErr error
for _, resource := range resources {
if resource == nil {
continue
}
if err := resource.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
if conn != nil {
if err := conn.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}

View file

@ -0,0 +1,440 @@
/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: session_test.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Tests für den Lebenszyklus einer Client-Session, die Verwaltung
* der zugehörigen Ressourcen und der dynamischen Data-Listener.
* ============================================================================
*/
package server_test
import (
"errors"
"net"
"testing"
"git.lang-dieter.de/rs2322tcp/internal/server"
)
///////////////////////////////////////////////////////////////////////////////
// Test resource
///////////////////////////////////////////////////////////////////////////////
type testResource struct {
closed bool
err error
}
func (r *testResource) Close() error {
r.closed = true
return r.err
}
///////////////////////////////////////////////////////////////////////////////
// Constructor
///////////////////////////////////////////////////////////////////////////////
func TestNewSession(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
session, err := server.NewSession(1, serverConn)
if err != nil {
t.Fatalf("NewSession() failed: %v", err)
}
if session.ID() != 1 {
t.Errorf("ID() = %d, want 1", session.ID())
}
if session.Conn() == nil {
t.Fatal("Conn() returned nil")
}
if session.IsClosed() {
t.Fatal("new session is already closed")
}
if session.ResourceCount() != 0 {
t.Errorf("ResourceCount() = %d, want 0",
session.ResourceCount())
}
if session.DataListenerCount() != 0 {
t.Errorf("DataListenerCount() = %d, want 0",
session.DataListenerCount())
}
}
func TestNewSessionRejectsNilConnection(t *testing.T) {
session, err := server.NewSession(1, nil)
if err == nil {
t.Fatal("NewSession() succeeded, want error")
}
if session != nil {
t.Fatal("NewSession() returned a session despite error")
}
}
func TestNewSessionRejectsZeroID(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
session, err := server.NewSession(0, serverConn)
if err == nil {
t.Fatal("NewSession() succeeded, want error")
}
if session != nil {
t.Fatal("NewSession() returned a session despite error")
}
}
///////////////////////////////////////////////////////////////////////////////
// Resources
///////////////////////////////////////////////////////////////////////////////
func TestSessionAddResource(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
session, err := server.NewSession(1, serverConn)
if err != nil {
t.Fatalf("NewSession() failed: %v", err)
}
resource1 := &testResource{}
resource2 := &testResource{}
if err := session.AddResource(resource1); err != nil {
t.Fatalf("AddResource(resource1) failed: %v", err)
}
if err := session.AddResource(resource2); err != nil {
t.Fatalf("AddResource(resource2) failed: %v", err)
}
if session.ResourceCount() != 2 {
t.Errorf("ResourceCount() = %d, want 2",
session.ResourceCount())
}
if resource1.closed {
t.Fatal("resource1 was closed before session.Close()")
}
if resource2.closed {
t.Fatal("resource2 was closed before session.Close()")
}
}
func TestAddNilResource(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
session, err := server.NewSession(1, serverConn)
if err != nil {
t.Fatalf("NewSession() failed: %v", err)
}
if err := session.AddResource(nil); err == nil {
t.Fatal("AddResource(nil) succeeded, want error")
}
}
func TestAddResourceAfterClose(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer clientConn.Close()
session, err := server.NewSession(1, serverConn)
if err != nil {
t.Fatalf("NewSession() failed: %v", err)
}
if err := session.Close(); err != nil {
t.Fatalf("Close() failed: %v", err)
}
resource := &testResource{}
if err := session.AddResource(resource); err == nil {
t.Fatal("AddResource() succeeded, want error")
}
if !resource.closed {
t.Fatal("resource was not closed after rejected AddResource()")
}
if session.ResourceCount() != 0 {
t.Errorf("ResourceCount() = %d, want 0",
session.ResourceCount())
}
}
///////////////////////////////////////////////////////////////////////////////
// Data listeners
///////////////////////////////////////////////////////////////////////////////
func TestSessionAddDataListener(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer clientConn.Close()
session, err := server.NewSession(1, serverConn)
if err != nil {
t.Fatalf("NewSession() failed: %v", err)
}
listener, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() failed: %v", err)
}
port := listener.Port()
if port == 0 {
t.Fatal("DataListener.Port() returned 0")
}
if err := session.AddDataListener("radio", listener); err != nil {
t.Fatalf("AddDataListener() failed: %v", err)
}
if session.DataListenerCount() != 1 {
t.Errorf("DataListenerCount() = %d, want 1",
session.DataListenerCount())
}
if session.ResourceCount() != 1 {
t.Errorf("ResourceCount() = %d, want 1",
session.ResourceCount())
}
storedListener, ok := session.DataListener("radio")
if !ok {
t.Fatal("DataListener(radio) not found")
}
if storedListener != listener {
t.Fatal("stored listener is not the same listener")
}
if session.DataPort("radio") != port {
t.Errorf("DataPort(radio) = %d, want %d",
session.DataPort("radio"), port)
}
if session.DataPort("unknown") != 0 {
t.Errorf("DataPort(unknown) = %d, want 0",
session.DataPort("unknown"))
}
}
func TestSessionRejectsDuplicateDataListener(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer clientConn.Close()
session, err := server.NewSession(1, serverConn)
if err != nil {
t.Fatalf("NewSession() failed: %v", err)
}
listener1, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() #1 failed: %v", err)
}
listener2, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() #2 failed: %v", err)
}
if err := session.AddDataListener("radio", listener1); err != nil {
t.Fatalf("AddDataListener() #1 failed: %v", err)
}
if err := session.AddDataListener("radio", listener2); err == nil {
t.Fatal("AddDataListener() #2 succeeded, want error")
}
if listener2.Port() != 0 {
t.Errorf("duplicate listener port = %d, want 0",
listener2.Port())
}
if session.DataListenerCount() != 1 {
t.Errorf("DataListenerCount() = %d, want 1",
session.DataListenerCount())
}
}
func TestSessionRejectsEmptyDeviceID(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer clientConn.Close()
session, err := server.NewSession(1, serverConn)
if err != nil {
t.Fatalf("NewSession() failed: %v", err)
}
listener, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() failed: %v", err)
}
if err := session.AddDataListener("", listener); err == nil {
t.Fatal("AddDataListener() succeeded, want error")
}
if listener.Port() != 0 {
t.Errorf("listener port = %d, want 0",
listener.Port())
}
}
func TestSessionClosesDataListener(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer clientConn.Close()
session, err := server.NewSession(1, serverConn)
if err != nil {
t.Fatalf("NewSession() failed: %v", err)
}
dataListener, err := server.NewDataListener()
if err != nil {
t.Fatalf("NewDataListener() failed: %v", err)
}
if err := session.AddDataListener("radio", dataListener); err != nil {
t.Fatalf("AddDataListener() failed: %v", err)
}
if session.DataListenerCount() != 1 {
t.Fatalf("DataListenerCount() = %d, want 1",
session.DataListenerCount())
}
if err := session.Close(); err != nil {
t.Fatalf("Session.Close() failed: %v", err)
}
if dataListener.Port() != 0 {
t.Errorf("DataListener.Port() after Session.Close() = %d, want 0",
dataListener.Port())
}
if session.DataListenerCount() != 0 {
t.Errorf("DataListenerCount() after Session.Close() = %d, want 0",
session.DataListenerCount())
}
if session.DataPort("radio") != 0 {
t.Errorf("DataPort(radio) after Close() = %d, want 0",
session.DataPort("radio"))
}
}
///////////////////////////////////////////////////////////////////////////////
// Close
///////////////////////////////////////////////////////////////////////////////
func TestSessionClose(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer clientConn.Close()
session, err := server.NewSession(1, serverConn)
if err != nil {
t.Fatalf("NewSession() failed: %v", err)
}
resource1 := &testResource{}
resource2 := &testResource{}
if err := session.AddResource(resource1); err != nil {
t.Fatalf("AddResource(resource1) failed: %v", err)
}
if err := session.AddResource(resource2); err != nil {
t.Fatalf("AddResource(resource2) failed: %v", err)
}
if err := session.Close(); err != nil {
t.Fatalf("Close() failed: %v", err)
}
if !session.IsClosed() {
t.Fatal("session is not closed")
}
if session.Conn() != nil {
t.Fatal("Conn() returned a connection after Close()")
}
if session.ResourceCount() != 0 {
t.Errorf("ResourceCount() after Close() = %d, want 0",
session.ResourceCount())
}
if !resource1.closed {
t.Fatal("resource1 was not closed")
}
if !resource2.closed {
t.Fatal("resource2 was not closed")
}
if err := session.Close(); err != nil {
t.Fatalf("second Close() failed: %v", err)
}
}
func TestSessionCloseResourceError(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer clientConn.Close()
session, err := server.NewSession(1, serverConn)
if err != nil {
t.Fatalf("NewSession() failed: %v", err)
}
expectedErr := errors.New("test resource error")
resource := &testResource{
err: expectedErr,
}
if err := session.AddResource(resource); err != nil {
t.Fatalf("AddResource() failed: %v", err)
}
err = session.Close()
if !errors.Is(err, expectedErr) {
t.Fatalf("Close() error = %v, want %v",
err, expectedErr)
}
if !resource.closed {
t.Fatal("resource was not closed")
}
}
func TestSessionCloseNil(t *testing.T) {
var session *server.Session
if err := session.Close(); err != nil {
t.Fatalf("Close() on nil session failed: %v", err)
}
}

101
internal/transport/codec.go Normal file
View file

@ -0,0 +1,101 @@
/*
Package transport contains the network transport definitions for rs2322tcp.
This file implements the framing of control protocol messages. Control
messages are encoded as one JSON object per line (JSON Lines).
Project: rs2322tcp
Module: git.lang-dieter.de/rs2322tcp
*/
package transport
import (
"bufio"
"encoding/json"
"fmt"
"io"
)
///////////////////////////////////////////////////////////////////////////////
// Constants
///////////////////////////////////////////////////////////////////////////////
const (
// MaximumControlMessageSize limits the size of one control message.
MaximumControlMessageSize = 64 * 1024
)
///////////////////////////////////////////////////////////////////////////////
// Writer
///////////////////////////////////////////////////////////////////////////////
// WriteMessage writes one control message as a JSON object followed by
// a newline.
func WriteMessage(w io.Writer, message interface{}) error {
if w == nil {
return fmt.Errorf("writer is nil")
}
if message == nil {
return fmt.Errorf("message is nil")
}
data, err := json.Marshal(message)
if err != nil {
return fmt.Errorf("encode control message: %w", err)
}
if len(data) > MaximumControlMessageSize {
return fmt.Errorf("control message too large: %d bytes",
len(data))
}
data = append(data, '\n')
if _, err := w.Write(data); err != nil {
return fmt.Errorf("write control message: %w", err)
}
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Reader
///////////////////////////////////////////////////////////////////////////////
// ReadMessage reads exactly one JSON Lines control message.
//
// The target must be a pointer to the expected message structure.
func ReadMessage(r *bufio.Reader, target interface{}) error {
if r == nil {
return fmt.Errorf("reader is nil")
}
if target == nil {
return fmt.Errorf("target is nil")
}
data, err := r.ReadBytes('\n')
if err != nil {
if err == io.EOF && len(data) == 0 {
return io.EOF
}
if err == io.EOF {
return fmt.Errorf("incomplete control message: %w", err)
}
return fmt.Errorf("read control message: %w", err)
}
if len(data) > MaximumControlMessageSize {
return fmt.Errorf("control message too large: %d bytes",
len(data))
}
if err := json.Unmarshal(data, target); err != nil {
return fmt.Errorf("decode control message: %w", err)
}
return nil
}

View file

@ -0,0 +1,164 @@
package transport_test
import (
"bufio"
"bytes"
"io"
"strings"
"testing"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
func TestWriteAndReadMessage(t *testing.T) {
original := transport.NewHello()
var buffer bytes.Buffer
if err := transport.WriteMessage(&buffer, original); err != nil {
t.Fatalf("WriteMessage() failed: %v", err)
}
reader := bufio.NewReader(&buffer)
var decoded transport.HelloMessage
if err := transport.ReadMessage(reader, &decoded); err != nil {
t.Fatalf("ReadMessage() failed: %v", err)
}
if decoded.Version != original.Version {
t.Errorf("Version = %d, want %d",
decoded.Version, original.Version)
}
if decoded.Type != original.Type {
t.Errorf("Type = %q, want %q",
decoded.Type, original.Type)
}
}
func TestMultipleMessages(t *testing.T) {
var buffer bytes.Buffer
if err := transport.WriteMessage(&buffer, transport.NewHello()); err != nil {
t.Fatalf("WriteMessage(hello) failed: %v", err)
}
if err := transport.WriteMessage(&buffer, transport.NewGetDevices()); err != nil {
t.Fatalf("WriteMessage(get_devices) failed: %v", err)
}
reader := bufio.NewReader(&buffer)
var hello transport.HelloMessage
if err := transport.ReadMessage(reader, &hello); err != nil {
t.Fatalf("ReadMessage(hello) failed: %v", err)
}
if hello.Type != transport.MessageHello {
t.Errorf("first Type = %q, want %q",
hello.Type, transport.MessageHello)
}
var getDevices transport.GetDevicesMessage
if err := transport.ReadMessage(reader, &getDevices); err != nil {
t.Fatalf("ReadMessage(get_devices) failed: %v", err)
}
if getDevices.Type != transport.MessageGetDevices {
t.Errorf("second Type = %q, want %q",
getDevices.Type, transport.MessageGetDevices)
}
}
func TestMessageFraming(t *testing.T) {
var buffer bytes.Buffer
if err := transport.WriteMessage(&buffer, transport.NewHello()); err != nil {
t.Fatalf("WriteMessage() failed: %v", err)
}
text := buffer.String()
if !strings.HasSuffix(text, "\n") {
t.Fatalf("message does not end with newline: %q", text)
}
if strings.Count(text, "\n") != 1 {
t.Fatalf("newline count = %d, want 1",
strings.Count(text, "\n"))
}
}
func TestReadMessageEOF(t *testing.T) {
reader := bufio.NewReader(strings.NewReader(""))
var message transport.HelloMessage
err := transport.ReadMessage(reader, &message)
if err != io.EOF {
t.Fatalf("ReadMessage() error = %v, want io.EOF", err)
}
}
func TestReadMessageInvalidJSON(t *testing.T) {
reader := bufio.NewReader(
strings.NewReader(`{"version":1,"type":invalid}` + "\n"),
)
var message transport.HelloMessage
if err := transport.ReadMessage(reader, &message); err == nil {
t.Fatal("ReadMessage() succeeded, want JSON error")
}
}
func TestReadMessageIncomplete(t *testing.T) {
reader := bufio.NewReader(
strings.NewReader(`{"version":1,"type":"hello"}`),
)
var message transport.HelloMessage
err := transport.ReadMessage(reader, &message)
if err == nil {
t.Fatal("ReadMessage() succeeded, want incomplete message error")
}
}
func TestNilWriter(t *testing.T) {
if err := transport.WriteMessage(nil, transport.NewHello()); err == nil {
t.Fatal("WriteMessage() succeeded, want error")
}
}
func TestNilMessage(t *testing.T) {
var buffer bytes.Buffer
if err := transport.WriteMessage(&buffer, nil); err == nil {
t.Fatal("WriteMessage() succeeded, want error")
}
}
func TestNilReader(t *testing.T) {
var message transport.HelloMessage
if err := transport.ReadMessage(nil, &message); err == nil {
t.Fatal("ReadMessage() succeeded, want error")
}
}
func TestNilTarget(t *testing.T) {
reader := bufio.NewReader(strings.NewReader(
`{"version":1,"type":"hello"}` + "\n",
))
if err := transport.ReadMessage(reader, nil); err == nil {
t.Fatal("ReadMessage() succeeded, want error")
}
}

View file

@ -0,0 +1,202 @@
/*
Package transport contains the network transport definitions for rs2322tcp.
This file defines the control protocol messages. It deliberately does not
contain any network I/O. The actual TCP implementation will be added later.
Project: rs2322tcp
Module: git.lang-dieter.de/rs2322tcp
*/
package transport
import (
"encoding/json"
"fmt"
"git.lang-dieter.de/rs2322tcp/internal/config"
)
///////////////////////////////////////////////////////////////////////////////
// Protocol
///////////////////////////////////////////////////////////////////////////////
// ProtocolVersion is the current control protocol version.
const ProtocolVersion = 1
// MessageType identifies a control protocol message.
type MessageType string
const (
MessageHello MessageType = "hello"
MessageHelloResponse MessageType = "hello_response"
MessageGetDevices MessageType = "get_devices"
MessageDeviceList MessageType = "device_list"
MessageError MessageType = "error"
)
///////////////////////////////////////////////////////////////////////////////
// Generic message
///////////////////////////////////////////////////////////////////////////////
// Message is the common envelope for all control protocol messages.
type Message struct {
Version int `json:"version"`
Type MessageType `json:"type"`
}
///////////////////////////////////////////////////////////////////////////////
// Hello
///////////////////////////////////////////////////////////////////////////////
// HelloMessage starts a control session.
type HelloMessage struct {
Message
}
// HelloResponseMessage confirms that the server accepts the protocol
// version used by the client.
type HelloResponseMessage struct {
Message
}
///////////////////////////////////////////////////////////////////////////////
// Get devices
///////////////////////////////////////////////////////////////////////////////
// GetDevicesMessage requests the devices currently available from the
// server.
type GetDevicesMessage struct {
Message
}
///////////////////////////////////////////////////////////////////////////////
// Device list
///////////////////////////////////////////////////////////////////////////////
// RemoteDeviceInfo describes a device returned by the server.
//
// DataPort is runtime information belonging to the current server session.
// It is therefore deliberately not part of config.RemoteDevice.
type RemoteDeviceInfo struct {
ID string `json:"id"`
Name string `json:"name"`
BaudRate int `json:"baud_rate"`
DataBits int `json:"data_bits"`
Parity string `json:"parity"`
StopBits int `json:"stop_bits"`
DataPort int `json:"data_port"`
}
// DeviceListMessage contains the devices currently available from the
// server and their session-specific data ports.
type DeviceListMessage struct {
Message
Devices []RemoteDeviceInfo `json:"devices"`
}
///////////////////////////////////////////////////////////////////////////////
// Error
///////////////////////////////////////////////////////////////////////////////
// ErrorCode identifies a protocol error.
type ErrorCode string
const (
ErrorUnknownMessage ErrorCode = "unknown_message"
ErrorUnsupportedVersion ErrorCode = "unsupported_version"
ErrorDeviceNotFound ErrorCode = "device_not_found"
ErrorDeviceUnavailable ErrorCode = "device_unavailable"
ErrorInternal ErrorCode = "internal_error"
)
// ErrorMessage reports a control protocol error.
type ErrorMessage struct {
Message
Code ErrorCode `json:"code"`
MessageText string `json:"message"`
}
///////////////////////////////////////////////////////////////////////////////
// Constructors
///////////////////////////////////////////////////////////////////////////////
// NewHello creates a hello message.
func NewHello() HelloMessage {
return HelloMessage{
Message: Message{
Version: ProtocolVersion,
Type: MessageHello,
},
}
}
// NewHelloResponse creates a hello response message.
func NewHelloResponse() HelloResponseMessage {
return HelloResponseMessage{
Message: Message{
Version: ProtocolVersion,
Type: MessageHelloResponse,
},
}
}
// NewGetDevices creates a get-devices message.
func NewGetDevices() GetDevicesMessage {
return GetDevicesMessage{
Message: Message{
Version: ProtocolVersion,
Type: MessageGetDevices,
},
}
}
// NewDeviceList creates a device-list message from a list of remote
// devices and their session-specific data ports.
func NewDeviceList(devices []RemoteDeviceInfo) DeviceListMessage {
return DeviceListMessage{
Message: Message{
Version: ProtocolVersion,
Type: MessageDeviceList,
},
Devices: devices,
}
}
// NewError creates an error message.
func NewError(code ErrorCode, message string) ErrorMessage {
return ErrorMessage{
Message: Message{
Version: ProtocolVersion,
Type: MessageError,
},
Code: code,
MessageText: message,
}
}
// NewRemoteDeviceInfo converts a public configuration device description
// into the transport representation and adds the session-specific data port.
func NewRemoteDeviceInfo(device config.RemoteDevice, dataPort int) RemoteDeviceInfo {
return RemoteDeviceInfo{
ID: device.ID,
Name: device.Name,
BaudRate: device.BaudRate,
DataBits: device.DataBits,
Parity: device.Parity,
StopBits: device.StopBits,
DataPort: dataPort,
}
}
///////////////////////////////////////////////////////////////////////////////
// JSON
///////////////////////////////////////////////////////////////////////////////
// EncodeMessage encodes a control message as JSON.
func EncodeMessage(message interface{}) ([]byte, error) {
if message == nil {
return nil, fmt.Errorf("message is nil")
}
return json.Marshal(message)
}

View file

@ -0,0 +1,125 @@
package transport_test
import (
"encoding/json"
"strings"
"testing"
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
func TestNewHello(t *testing.T) {
message := transport.NewHello()
if message.Version != transport.ProtocolVersion {
t.Errorf("Version = %d, want %d",
message.Version, transport.ProtocolVersion)
}
if message.Type != transport.MessageHello {
t.Errorf("Type = %q, want %q",
message.Type, transport.MessageHello)
}
}
func TestNewGetDevices(t *testing.T) {
message := transport.NewGetDevices()
if message.Version != transport.ProtocolVersion {
t.Errorf("Version = %d, want %d",
message.Version, transport.ProtocolVersion)
}
if message.Type != transport.MessageGetDevices {
t.Errorf("Type = %q, want %q",
message.Type, transport.MessageGetDevices)
}
}
func TestDeviceListJSON(t *testing.T) {
message := transport.NewDeviceList([]transport.RemoteDeviceInfo{
{
ID: "radio",
Name: "Funkgerät",
BaudRate: 9600,
DataBits: 8,
Parity: "none",
StopBits: 1,
DataPort: 43721,
},
})
data, err := transport.EncodeMessage(message)
if err != nil {
t.Fatalf("EncodeMessage() failed: %v", err)
}
jsonText := string(data)
if !strings.Contains(jsonText, `"type":"device_list"`) {
t.Fatalf("JSON does not contain device_list: %s", jsonText)
}
if !strings.Contains(jsonText, `"data_port":43721`) {
t.Fatalf("JSON does not contain data port: %s", jsonText)
}
if strings.Contains(jsonText, "serial_port") {
t.Fatalf("JSON contains server-internal serial_port")
}
}
func TestNewRemoteDeviceInfo(t *testing.T) {
device := config.RemoteDevice{
ID: "radio",
Name: "Funkgerät",
BaudRate: 9600,
DataBits: 8,
Parity: "none",
StopBits: 1,
}
info := transport.NewRemoteDeviceInfo(device, 43721)
if info.ID != "radio" {
t.Errorf("ID = %q, want %q", info.ID, "radio")
}
if info.DataPort != 43721 {
t.Errorf("DataPort = %d, want %d", info.DataPort, 43721)
}
}
func TestErrorMessageJSON(t *testing.T) {
message := transport.NewError(
transport.ErrorUnknownMessage,
"unknown message type",
)
data, err := transport.EncodeMessage(message)
if err != nil {
t.Fatalf("EncodeMessage() failed: %v", err)
}
var decoded map[string]interface{}
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("json.Unmarshal() failed: %v", err)
}
if decoded["type"] != string(transport.MessageError) {
t.Errorf("type = %v, want %q",
decoded["type"], transport.MessageError)
}
if decoded["code"] != string(transport.ErrorUnknownMessage) {
t.Errorf("code = %v, want %q",
decoded["code"], transport.ErrorUnknownMessage)
}
if decoded["message"] != "unknown message type" {
t.Errorf("message = %v, want %q",
decoded["message"], "unknown message type")
}
}