From 8d98e85453a7b590f0a7eafc9c9c39bb4d30d24c Mon Sep 17 00:00:00 2001 From: Dieter Lang Date: Tue, 11 Aug 2026 19:07:20 +0200 Subject: [PATCH] Add working hardware connectivity and SPID rotor support --- README.md | 186 +++++++++++++++++++++++++++++ configs/server.json | 8 +- internal/client/application.go | 38 ++++-- internal/config/config.go | 1 + internal/server/control.go | 6 + internal/server/data_connection.go | 109 ++++++++++++++++- 6 files changed, 332 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0275fb7..3e56c34 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,192 @@ analysieren zu können. Während der Entwicklung kann `socat` als zusätzliches Werkzeug für Tests und Diagnose eingesetzt werden. +## Hardwaretest mit SPID Rot2Prog + +Für den praktischen Hardwaretest wurde ein SPID Rot2Prog als +Antennenrotor über einen FT232R USB-to-RS232-Adapter angeschlossen. + +Unter Linux wurde der Adapter als: + +```text +/dev/ttyUSB0 +``` + +erkannt. + +Der Rot2Prog verwendet für die RS232-Kommunikation: + +```text +600 Baud +8 Datenbits +keine Parität +1 Stopbit +``` + +also: + +```text +600 8N1 +``` + +### Rotor einschalten + +Vor dem Kommunikationstest muss der SPID-Rot2Prog eingeschaltet sein. + +Der Controller muss für die serielle Steuerung im SPID-/Auto-Betrieb +betrieben werden. + +Der Zustand des Controllers kann über die vorhandenen Bedientasten +und die LED-Anzeigen kontrolliert werden. + +### Direkter Hardwaretest + +Der serielle Port kann zunächst unabhängig von `rs2322tcp` getestet +werden: + +```bash +stty -F /dev/ttyUSB0 600 cs8 -cstopb -parenb raw -echo +``` + +Kontrolle: + +```bash +stty -F /dev/ttyUSB0 +``` + +Anschließend kann die serielle Antwort des Controllers beobachtet werden: + +```bash +cat /dev/ttyUSB0 | xxd -g 1 +``` + +Eine Statusabfrage kann beispielsweise mit folgendem Paket gesendet +werden: + +```bash +printf '\x57\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1f\x20' > /dev/ttyUSB0 +``` + +Bei erfolgreicher Kommunikation antwortet der Rot2Prog mit einem +12-Byte-Datenpaket. + +### Test über rs2322tcp + +Nach erfolgreichem direkten Hardwaretest wird der reale serielle Port +im Server konfiguriert: + +```text +Rotor: + /dev/ttyUSB0 + 600 Baud + 8 Datenbits + keine Parität + 1 Stopbit +``` + +Der Client stellt dem Anwender dafür den virtuellen Port: + +```text +/dev/ttyUSB101 +``` + +zur Verfügung. + +Die gleiche Statusabfrage kann anschließend über den vollständigen +rs2322tcp-Datenpfad gesendet werden: + +```bash +printf '\x57\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1f\x20' > /dev/ttyUSB101 +``` + +Der Datenweg ist dann: + +```text +/dev/ttyUSB101 + │ + ▼ +rs2322tcp-client + │ + │ TCP + ▼ +rs2322tcp-server + │ + ▼ +/dev/ttyUSB0 + │ + │ 600 Baud / 8N1 + ▼ +SPID Rot2Prog + │ + │ Antwort + ▼ +rs2322tcp-server + │ + │ TCP + ▼ +rs2322tcp-client + │ + ▼ +/dev/ttyUSB101 +``` + +Damit kann die Antwort des Rotors wieder über den virtuellen seriellen +Port gelesen werden. + +Ein erfolgreich beobachteter Antwort-Datenstrom war: + +```text +57 03 06 05 0C 01 03 06 02 0F 01 20 +``` + +Damit wurde die bidirektionale Übertragung zwischen dem virtuellen +seriellen Port des Clients und dem realen SPID-Rot2Prog über TCP +erfolgreich nachgewiesen. + +## Manuelle Einrichtung der virtuellen seriellen Ports + +Die virtuellen seriellen Ports werden vor dem Start des Clients manuell +eingerichtet. Dadurch muss der `rs2322tcp-client` selbst nicht als +`root` bzw. mit `sudo` laufen. + +Zunächst das Verzeichnis für die internen virtuellen Links anlegen: + +```bash +mkdir -p ~/.rs2322tcp/virtual +``` + +Anschließend werden die öffentlichen `/dev/ttyUSBxxx`-Links einmalig +mit administrativen Rechten angelegt: + +```bash +sudo ln -s "$HOME/.rs2322tcp/virtual/ttyUSB100" /dev/ttyUSB100 +sudo ln -s "$HOME/.rs2322tcp/virtual/ttyUSB101" /dev/ttyUSB101 +``` + +Die Zuordnung ist: + +```text +/dev/ttyUSB100 -> ~/.rs2322tcp/virtual/ttyUSB100 -> PTY +/dev/ttyUSB101 -> ~/.rs2322tcp/virtual/ttyUSB101 -> PTY +``` + +Die äußeren Links unter `/dev` gehören dabei `root`. Das ist beabsichtigt. +Der Client selbst läuft anschließend als normaler Benutzer. + +Die internen Links unter `~/.rs2322tcp/virtual/` werden vom Client auf die +jeweils verwendeten PTYs gesetzt. + +Die beiden virtuellen Ports werden in der Client-Konfiguration den +Geräten zugeordnet: + +```text +/dev/ttyUSB100 -> radio +/dev/ttyUSB101 -> rotor +``` + +Die manuelle Einrichtung muss nur erfolgen, wenn die `/dev/ttyUSB100`- +und `/dev/ttyUSB101`-Links noch nicht vorhanden sind. + ## Virtuelle serielle Schnittstelle unter Linux Für den Linux-Client wird die virtuelle serielle Schnittstelle direkt diff --git a/configs/server.json b/configs/server.json index 977ef60..5f1be34 100644 --- a/configs/server.json +++ b/configs/server.json @@ -6,11 +6,13 @@ "hardware_error_response": "ERROR - HARDWARE NOT AVAILABLE", + "serial_monitor": true, + "devices": [ { "id": "radio", "name": "Funkgerät", - "serial_port": "/dev/ttyUSB0", + "serial_port": "/dev/ttyUSB1", "baud_rate": 9600, "data_bits": 8, "parity": "none", @@ -19,8 +21,8 @@ { "id": "rotor", "name": "Antennenrotor", - "serial_port": "/dev/ttyUSB1", - "baud_rate": 4800, + "serial_port": "/dev/ttyUSB0", + "baud_rate": 600, "data_bits": 8, "parity": "none", "stop_bits": 1 diff --git a/internal/client/application.go b/internal/client/application.go index f4a69a0..7d5617b 100644 --- a/internal/client/application.go +++ b/internal/client/application.go @@ -73,7 +73,6 @@ type Application struct { // The client configuration is loaded immediately. No network connection // is established. Start must be called before the application connects to // the server or starts the Runtime. - func NewApplication(configFile string) (*Application, error) { if configFile == "" { return nil, fmt.Errorf("configuration file is empty") @@ -94,14 +93,11 @@ func NewApplication(configFile string) (*Application, error) { // Start /////////////////////////////////////////////////////////////////////////////// -// Start loads the client configuration and connects to the server. +// Start loads the client configuration, connects to the server and starts +// the client Runtime. // -// Start is intended for the initial application startup. An already running -// application must be stopped first by calling Close or Reconnect. -// -// The server device list is retrieved during startup. The client Runtime is -// not started here; data connections are established separately when the -// runtime is explicitly started. +// The Runtime is started asynchronously because Runtime.Run blocks while +// the configured virtual serial connections are active. func (a *Application) Start() error { if a == nil { return fmt.Errorf("application is nil") @@ -140,17 +136,39 @@ func (a *Application) Start() error { return fmt.Errorf("get remote devices: %w", err) } + manager := NewVirtualPortManager( + cfg.VirtualPortRange, + ) + + runtime, err := NewRuntime( + client, + manager, + ) + if err != nil { + _ = client.Close() + + return fmt.Errorf("create runtime: %w", err) + } + + runtimeDone := make(chan error, 1) + a.mu.Lock() + a.config = cfg a.client = client - a.runtime = nil + a.runtime = runtime a.devices = append( []transport.RemoteDeviceInfo(nil), devices..., ) - a.runtimeDone = nil + a.runtimeDone = runtimeDone + a.mu.Unlock() + go func() { + runtimeDone <- runtime.Run(*cfg) + }() + return nil } diff --git a/internal/config/config.go b/internal/config/config.go index 59ccdc9..1a77774 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ import ( type ServerConfig struct { Listen ListenConfig `json:"listen"` HardwareErrorResponse string `json:"hardware_error_response"` + SerialMonitor bool `json:"serial_monitor"` Devices []DeviceConfig `json:"devices"` } diff --git a/internal/server/control.go b/internal/server/control.go index f79215a..f8957b9 100644 --- a/internal/server/control.go +++ b/internal/server/control.go @@ -352,6 +352,11 @@ func (s *ControlServer) runDataListener( continue } + dataConnection.SetSerialMonitor( + s.config.SerialMonitor, + device.SerialPort, + ) + if err := session.AddDataConnection( device.ID, dataConnection, @@ -363,6 +368,7 @@ func (s *ControlServer) runDataListener( err, ) + _ = dataConnection.Close() continue } diff --git a/internal/server/data_connection.go b/internal/server/data_connection.go index 586881a..04ebc5c 100644 --- a/internal/server/data_connection.go +++ b/internal/server/data_connection.go @@ -16,6 +16,7 @@ package server import ( "fmt" "io" + "log" "net" "sync" ) @@ -43,6 +44,9 @@ type DataConnection struct { serial io.ReadWriteCloser hardwareErrorResponse string + serialMonitor bool + serialPort string + closeOnce sync.Once closeErr error } @@ -98,6 +102,60 @@ func (c *DataConnection) SerialConn() io.ReadWriteCloser { return c.serial } +// SetSerialMonitor enables or disables the data monitor. +// +// If enabled, transmitted and received data is written to the server log. +// serialPort is used only for identifying the physical interface in the +// monitor output. +func (c *DataConnection) SetSerialMonitor( + enabled bool, + serialPort string, +) { + if c == nil { + return + } + + c.serialMonitor = enabled + c.serialPort = serialPort +} + +/////////////////////////////////////////////////////////////////////////////// +// Data monitor +/////////////////////////////////////////////////////////////////////////////// + +// logTCPData writes TCP data to the server log. +func (c *DataConnection) logTCPData( + direction string, + data []byte, +) { + if c == nil || !c.serialMonitor || len(data) == 0 { + return + } + + log.Printf( + "%s % X", + direction, + data, + ) +} + +// logSerialData writes serial data to the server log. +func (c *DataConnection) logSerialData( + direction string, + data []byte, +) { + if c == nil || !c.serialMonitor || len(data) == 0 { + return + } + + log.Printf( + "%s [SERIAL %s] % X", + direction, + c.serialPort, + data, + ) +} + /////////////////////////////////////////////////////////////////////////////// // Data transfer /////////////////////////////////////////////////////////////////////////////// @@ -118,7 +176,11 @@ func (c *DataConnection) copyTCPToSerial() error { n, err := c.tcp.Read(buffer) if n > 0 { - if _, writeErr := c.serial.Write(buffer[:n]); writeErr != nil { + c.logTCPData("TCP RX", buffer[:n]) + + written, writeErr := c.serial.Write(buffer[:n]) + + if writeErr != nil { _, _ = io.WriteString( c.tcp, c.hardwareErrorResponse, @@ -126,6 +188,17 @@ func (c *DataConnection) copyTCPToSerial() error { return writeErr } + + if written != n { + _, _ = io.WriteString( + c.tcp, + c.hardwareErrorResponse, + ) + + return io.ErrShortWrite + } + + c.logSerialData("SERIAL TX", buffer[:n]) } if err != nil { @@ -141,9 +214,39 @@ func (c *DataConnection) copySerialToTCP() error { return fmt.Errorf("data connection is nil") } - _, err := io.Copy(c.tcp, c.serial) + buffer := make([]byte, 32*1024) - return err + for { + n, err := c.serial.Read(buffer) + + if n > 0 { + c.logSerialData("SERIAL RX", buffer[:n]) + + written := 0 + + for written < n { + count, writeErr := c.tcp.Write( + buffer[written:n], + ) + + written += count + + if writeErr != nil { + return writeErr + } + + if count == 0 { + return io.ErrShortWrite + } + } + + c.logTCPData("TCP TX", buffer[:n]) + } + + if err != nil { + return err + } + } } ///////////////////////////////////////////////////////////////////////////////