Add working hardware connectivity and SPID rotor support

This commit is contained in:
Dieter Lang 2026-08-11 19:07:20 +02:00
parent 1621ed4330
commit 8d98e85453
6 changed files with 332 additions and 16 deletions

186
README.md
View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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
}
}
}
///////////////////////////////////////////////////////////////////////////////