Compare commits
21 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b7d081960 | ||
| da212e943b | |||
| 3a85b17422 | |||
|
|
c9e3ba052a | ||
| d890aba7b4 | |||
| 0eccd003f0 | |||
| f2428a2007 | |||
| 21d622e040 | |||
|
|
d9a70b9225 | ||
|
|
61bd03b863 | ||
| a6cd04ece4 | |||
| 39a1fea75d | |||
|
|
81b06a16a0 | ||
|
|
90189ab4dc | ||
|
|
5e595fe460 | ||
|
|
8d98e85453 | ||
|
|
1621ed4330 | ||
|
|
a33546fbed | ||
|
|
084ca66c7b | ||
|
|
80e4196e16 | ||
|
|
f3610b989c |
53 changed files with 7048 additions and 336 deletions
193
CHANGELOG.md
193
CHANGELOG.md
|
|
@ -2,6 +2,199 @@
|
|||
|
||||
Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert.
|
||||
|
||||
## [0.0.11] - 2026-08-25
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- Windows-Client erfolgreich mit Fyne-GUI gestartet.
|
||||
- Übertragung der konfigurierten `startup_bytes` an das zugeordnete
|
||||
Remote-Gerät beim Verbindungsaufbau getestet.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Windows-spezifische Behandlung virtueller serieller Ports angepasst.
|
||||
- Öffnen und Verwaltung virtueller serieller Schnittstellen unter Windows
|
||||
überarbeitet.
|
||||
|
||||
### Getestet
|
||||
|
||||
- Minimaler Fyne-Test unter Windows erfolgreich gestartet.
|
||||
- `rs2322tcp-client` unter Windows erfolgreich mit Fyne-GUI gestartet.
|
||||
- Verbindung des Windows-Clients zum `rs2322tcp-server` erfolgreich aufgebaut.
|
||||
- Rotor-Startcode erfolgreich vom Windows-Client bis zum Server übertragen.
|
||||
|
||||
## [0.0.10] - 2026-08-21
|
||||
|
||||
### Behoben
|
||||
|
||||
- Linux-Portverwaltung nach der Windows-Client-Erweiterung korrigiert.
|
||||
- Doppelte Linux-Definitionen der virtuellen Portverwaltung entfernt.
|
||||
- Plattformübergreifende Trennung der virtuellen Port-Erzeugung korrigiert.
|
||||
- Dauerhafte Installations-Symlinks für virtuelle Linux-Ports bleiben beim Schließen eines Ports erhalten.
|
||||
- Gesamter Go-Testlauf unter Linux erfolgreich.
|
||||
|
||||
## [0.0.9] - 2026-08-21
|
||||
|
||||
### Added
|
||||
|
||||
- Windows-Unterstützung für den rs2322tcp-Client
|
||||
- Plattformabhängige Verwaltung virtueller serieller Schnittstellen
|
||||
- Windows-COM-Port-Unterstützung über `go.bug.st/serial`
|
||||
- Unterstützung des virtuellen Windows-Portbereichs COM100 bis COM199
|
||||
- Windows-spezifische Portvalidierung und Portpfadverwaltung
|
||||
- Technischer Windows-Client unter `cmd/rs2322tcp-client-test`
|
||||
- Plattformabhängige Implementierungen für das Öffnen und Verwalten
|
||||
virtueller serieller Schnittstellen
|
||||
- Windows-spezifische Implementierung von `VirtualSerial`
|
||||
|
||||
### Changed
|
||||
|
||||
- Virtuelle Portverwaltung für Linux und Windows auf eine gemeinsame
|
||||
Schnittstelle umgestellt
|
||||
- Clientseitige Portvalidierung für die unterschiedlichen Plattformen
|
||||
erweitert
|
||||
- Client-Konfiguration für den Windows-Betrieb angepasst
|
||||
|
||||
### Tests
|
||||
|
||||
- Windows 11 / amd64 erfolgreich getestet
|
||||
- Windows-COM-Port COM100 über com0com erfolgreich geöffnet
|
||||
- com0com-Portpaar `COM10 ↔ COM100` erfolgreich getestet
|
||||
- Verbindung des Windows-Clients über Tailscale zum Raspberry-Pi-Server
|
||||
erfolgreich getestet
|
||||
- Bidirektionale Datenübertragung zwischen Windows-Client und
|
||||
Raspberry-Pi-Server erfolgreich getestet
|
||||
- Bidirektionale Datenübertragung mit einem realen SPID Rot2Prog
|
||||
erfolgreich getestet
|
||||
- Rot2Prog mit `P5 = 59` (SPID) und Function Mode `A` (Auto) erfolgreich
|
||||
betrieben
|
||||
- Tatsächliche Antwort des Rot2Prog erfolgreich über den vollständigen
|
||||
Datenweg übertragen:
|
||||
|
||||
```text
|
||||
57 07 01 06 00 01 03 06 02 00 01 20
|
||||
```
|
||||
|
||||
- `go test ./internal/client` erfolgreich
|
||||
|
||||
### Status
|
||||
|
||||
Der Windows-Client kann über einen virtuellen Windows-COM-Port mit dem
|
||||
rs2322tcp-Server auf einem Raspberry Pi kommunizieren.
|
||||
|
||||
Der vollständige Datenweg wurde mit einem realen SPID Rot2Prog
|
||||
bidirektional erfolgreich getestet:
|
||||
|
||||
```text
|
||||
COM10
|
||||
│
|
||||
▼
|
||||
com0com
|
||||
│
|
||||
▼
|
||||
COM100
|
||||
│
|
||||
▼
|
||||
rs2322tcp-client (Windows)
|
||||
│
|
||||
▼
|
||||
TCP / Tailscale
|
||||
│
|
||||
▼
|
||||
rs2322tcp-server (Raspberry Pi)
|
||||
│
|
||||
▼
|
||||
RS232 / SPID Rot2Prog
|
||||
```
|
||||
|
||||
Der technische Windows-Client bleibt als separates Testprogramm erhalten.
|
||||
|
||||
Die grafische Fyne-Oberfläche des Windows-Clients ist noch nicht
|
||||
vollständig abgeschlossen.
|
||||
|
||||
|
||||
## [0.0.8] - 2026-08-12
|
||||
|
||||
### Added
|
||||
|
||||
- Prüfung der lokal vorhandenen virtuellen Ports beim Client-Start
|
||||
- Abgleich der vorhandenen symbolischen Links mit den in `client.json`
|
||||
definierten virtuellen Schnittstellen
|
||||
- Erkennung von in `client.json` definierten, aber lokal nicht vorhandenen
|
||||
virtuellen Ports
|
||||
- Erkennung von lokal vorhandenen, aber nicht in `client.json` definierten
|
||||
virtuellen Ports
|
||||
- Verständlicher Fehlerdialog bei einer abweichenden virtuellen
|
||||
Portkonfiguration
|
||||
- Beenden des Clients nach Bestätigung einer fehlerhaften
|
||||
Portkonfiguration
|
||||
|
||||
### Changed
|
||||
|
||||
- Virtuelle Ports werden entsprechend der in `client.json` festgelegten
|
||||
Portnummern gezielt geöffnet
|
||||
- Die Verwaltung virtueller Ports arbeitet weiterhin ohne Root-Rechte
|
||||
- Der Fehlerdialog verwendet einen einzelnen OK-Button
|
||||
- Allgemeine Schriftgröße der grafischen Benutzeroberfläche auf 22 erhöht
|
||||
- Hauptfenster des Clients auf 800 × 600 Pixel vergrößert
|
||||
|
||||
### Tests
|
||||
|
||||
- Prüfung vorhandener und konfigurierter virtueller Ports erfolgreich getestet
|
||||
- Fehlender virtueller Port erfolgreich erkannt und im Fehlerdialog angezeigt
|
||||
- Zusätzlicher, nicht konfigurierter virtueller Port erfolgreich erkannt
|
||||
- Clientstart nach Korrektur der virtuellen Portkonfiguration erfolgreich getestet
|
||||
- Gerätezuordnung nach Neustart des Clients erfolgreich aus `client.json`
|
||||
geladen
|
||||
- `go test ./...` erfolgreich
|
||||
- `go test -race ./internal/client` erfolgreich
|
||||
- `go test -race ./internal/gui` erfolgreich
|
||||
- `git diff --check` erfolgreich
|
||||
|
||||
## [0.0.7] - 2026-08-12
|
||||
|
||||
### Added
|
||||
|
||||
- Pflege der Gerätezuordnung über die grafische Benutzeroberfläche
|
||||
- Zuordnung der vom Server angebotenen Geräte zu den konfigurierten
|
||||
virtuellen seriellen Schnittstellen
|
||||
- Unterstützung des Zustands „nicht verbunden“
|
||||
- Freie virtuelle Schnittstellen werden bei der Gerätezuordnung automatisch
|
||||
neu angeboten
|
||||
- Verhinderung der gleichzeitigen Zuordnung einer virtuellen Schnittstelle
|
||||
zu mehreren Geräten
|
||||
- Bereits zugeordnete Geräte können nur auf ihre aktuelle Schnittstelle oder
|
||||
„nicht verbunden“ gesetzt werden
|
||||
- Ungespeicherte Änderungen werden beim Schließen des Hauptfensters erkannt
|
||||
und dem Anwender angezeigt
|
||||
- Änderungen werden beim Schließen des Gerätezuordnungsfensters in der
|
||||
Client-Konfiguration gespeichert
|
||||
- Virtuelle Schnittstellen bleiben auch ohne Gerätezuordnung in der
|
||||
Client-Konfiguration erhalten
|
||||
- Ein leerer Wert für `remote_device` kennzeichnet eine freie virtuelle
|
||||
Schnittstelle
|
||||
- `Application.SaveConfig()` zum zentralen Speichern der Client-Konfiguration
|
||||
|
||||
### Changed
|
||||
|
||||
- Validierung der Client-Konfiguration erlaubt nun virtuelle Schnittstellen
|
||||
ohne aktuelle Gerätezuordnung
|
||||
- Die Gerätezuordnung arbeitet auf Basis der vorhandenen virtuellen
|
||||
Schnittstellen und verändert bei einer Trennung nur deren
|
||||
`remote_device`-Zuordnung
|
||||
|
||||
### Tests
|
||||
|
||||
- Gerätezuordnung mit mehreren Server-Geräten praktisch getestet
|
||||
- Freigabe einer virtuellen Schnittstelle und anschließende erneute Auswahl
|
||||
erfolgreich getestet
|
||||
- Speicherung einer Gerätezuordnung über die GUI erfolgreich getestet
|
||||
- Speicherung einer getrennten Gerätezuordnung mit leerem
|
||||
`remote_device` erfolgreich getestet
|
||||
- `go test ./internal/config` erfolgreich
|
||||
- `go test ./internal/gui` erfolgreich
|
||||
- `go test -race ./internal/gui` erfolgreich
|
||||
- `git diff --check` erfolgreich
|
||||
## [0.0.6] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
|
|
|||
704
README.md
704
README.md
|
|
@ -48,9 +48,9 @@ rs2322tcp-client │
|
|||
Funkgerät Rotor
|
||||
```
|
||||
|
||||
## Aktueller Server-Datenpfad
|
||||
## Aktueller Datenpfad
|
||||
|
||||
Die serverseitige TCP-/RS232-Verbindung ist implementiert:
|
||||
Der serverseitige TCP-/RS232-Datenpfad ist implementiert.
|
||||
|
||||
```text
|
||||
TCP-Control
|
||||
|
|
@ -85,32 +85,88 @@ TCP ───────────────► RS232
|
|||
TCP ◄────────────── RS232
|
||||
```
|
||||
|
||||
Auch der Client-Datenpfad ist implementiert:
|
||||
|
||||
```text
|
||||
Virtueller serieller Port
|
||||
│
|
||||
▼
|
||||
rs2322tcp-client
|
||||
│
|
||||
│ TCP-Control
|
||||
▼
|
||||
Session
|
||||
│
|
||||
│ TCP-Data
|
||||
▼
|
||||
rs2322tcp-server
|
||||
│
|
||||
▼
|
||||
reale serielle
|
||||
Schnittstelle
|
||||
│
|
||||
▼
|
||||
Gerät
|
||||
```
|
||||
|
||||
Der Client kann mehrere konfigurierte virtuelle serielle Schnittstellen
|
||||
gleichzeitig verwalten.
|
||||
|
||||
## Client
|
||||
|
||||
Der Client soll gleichberechtigt unter folgenden Betriebssystemen
|
||||
eingesetzt werden können:
|
||||
|
||||
- Windows
|
||||
- Linux
|
||||
- Windows
|
||||
|
||||
Die plattformspezifische Bereitstellung der virtuellen seriellen
|
||||
Schnittstelle wird vom gemeinsamen Client-Kern getrennt.
|
||||
Die gemeinsame Client-Logik ist von der plattformspezifischen
|
||||
Bereitstellung der virtuellen seriellen Schnittstelle getrennt.
|
||||
|
||||
Unter Linux ist die virtuelle serielle Schnittstelle inzwischen über
|
||||
einen direkt aus Go erzeugten PTY implementiert. Die Implementierung
|
||||
verwendet `/dev/ptmx` und stellt den zugehörigen PTY-Slave unter
|
||||
`/dev/pts/N` bereit.
|
||||
Der Client besteht im Wesentlichen aus:
|
||||
|
||||
- Control-Verbindung zum Server
|
||||
- Verwaltung der vom Server angebotenen Geräte
|
||||
- Runtime zur Verwaltung mehrerer virtueller Ports
|
||||
- virtuellen seriellen Schnittstellen
|
||||
- TCP-Data-Connections
|
||||
- bidirektionalen Bridges zwischen virtuellem Port und TCP-Verbindung
|
||||
- grafischer Benutzeroberfläche
|
||||
|
||||
### Linux
|
||||
|
||||
Unter Linux ist die virtuelle serielle Schnittstelle über einen direkt
|
||||
aus Go erzeugten PTY implementiert.
|
||||
|
||||
Die Implementierung verwendet `/dev/ptmx` und stellt den zugehörigen
|
||||
PTY-Slave unter `/dev/pts/N` bereit.
|
||||
|
||||
Die PTY-Schnittstelle wird im Raw Mode betrieben. Dadurch werden die
|
||||
übertragenen Daten als transparenter Byte-Strom behandelt.
|
||||
|
||||
Die allgemeine Client-Logik verwendet dafür die Abstraktion
|
||||
`VirtualSerial`. Die Linux-spezifische Implementierung ist von dieser
|
||||
Abstraktion getrennt.
|
||||
`VirtualSerial`.
|
||||
|
||||
Die Linux-spezifische Implementierung ist von dieser Abstraktion
|
||||
getrennt.
|
||||
|
||||
Für die PTY-Funktion ist keine externe Laufzeitabhängigkeit wie `socat`
|
||||
erforderlich.
|
||||
|
||||
### Windows
|
||||
|
||||
Die gemeinsame Client-Architektur ist bereits auf eine
|
||||
plattformabhängige Implementierung der virtuellen seriellen
|
||||
Schnittstelle vorbereitet.
|
||||
|
||||
Unter Windows wird dieselbe gemeinsame Client- und Konfigurationslogik
|
||||
verwendet. Für Tests können vorhandene virtuelle COM-Port-Verbindungen
|
||||
verwendet werden.
|
||||
|
||||
Mit Version `v0.0.11` wurde der Windows-Client mit funktionierender
|
||||
Fyne-GUI sowie der automatischen Übertragung der konfigurierten Rotor-
|
||||
Startbytes praktisch getestet.
|
||||
|
||||
## Server
|
||||
|
||||
Der Server ist zunächst für den Betrieb auf einem Raspberry Pi 5
|
||||
|
|
@ -159,30 +215,174 @@ untersucht werden.
|
|||
|
||||
Client und Server erhalten jeweils eine eigene JSON-Konfiguration.
|
||||
|
||||
Über die Konfiguration werden unter anderem festgelegt:
|
||||
### Client-Konfiguration
|
||||
|
||||
- Netzwerkparameter
|
||||
- serielle Schnittstelle
|
||||
- Baudrate
|
||||
- Datenbits
|
||||
- Parität
|
||||
- Stopbits
|
||||
- Bezeichnung des Gerätes
|
||||
- weitere für die jeweilige Schnittstelle erforderliche Parameter
|
||||
Über die Client-Konfiguration werden unter anderem festgelegt:
|
||||
|
||||
Die konkrete Konfigurationsstruktur befindet sich in der Entwicklung.
|
||||
- Server-Adresse
|
||||
- Server-Port
|
||||
- reservierter Bereich der virtuellen seriellen Schnittstellen
|
||||
- Zuordnung virtueller Schnittstellen zu Remote-Geräten
|
||||
|
||||
## Diagnose und Logging
|
||||
Beispiel:
|
||||
|
||||
Für Debugging und Diagnose soll eine Protokollierung des übertragenen
|
||||
Datenverkehrs möglich sein.
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"address": "127.0.0.1",
|
||||
"port": 5000
|
||||
},
|
||||
"virtual_port_range": {
|
||||
"first": 100,
|
||||
"last": 199
|
||||
},
|
||||
"virtual_ports": [
|
||||
{
|
||||
"port": "/dev/ttyUSB100",
|
||||
"remote_device": "radio"
|
||||
},
|
||||
{
|
||||
"port": "/dev/ttyUSB101",
|
||||
"remote_device": "rotor"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Insbesondere soll eine Darstellung der übertragenen Bytes möglich sein,
|
||||
um Probleme bei der Kommunikation zwischen Hersteller-Software und Gerät
|
||||
analysieren zu können.
|
||||
Der konfigurierte Bereich
|
||||
|
||||
Während der Entwicklung kann `socat` als zusätzliches Werkzeug für Tests
|
||||
und Diagnose eingesetzt werden.
|
||||
```json
|
||||
"virtual_port_range": {
|
||||
"first": 100,
|
||||
"last": 199
|
||||
}
|
||||
```
|
||||
|
||||
definiert den für `rs2322tcp` vorgesehenen Bereich virtueller
|
||||
Schnittstellen.
|
||||
|
||||
Die konkrete Zuordnung wird über `virtual_ports` festgelegt.
|
||||
|
||||
Beispielsweise:
|
||||
|
||||
```text
|
||||
/dev/ttyUSB100 -> radio
|
||||
/dev/ttyUSB101 -> rotor
|
||||
```
|
||||
|
||||
Ein leerer Wert für `remote_device` kennzeichnet eine freie virtuelle
|
||||
Schnittstelle.
|
||||
|
||||
Virtuelle Schnittstellen bleiben dadurch auch ohne aktuelle
|
||||
Gerätezuordnung in der Client-Konfiguration erhalten.
|
||||
|
||||
### Gerätezuordnung
|
||||
|
||||
Die vom Server angebotenen Geräte können über die grafische
|
||||
Benutzeroberfläche den konfigurierten virtuellen Schnittstellen
|
||||
zugeordnet werden.
|
||||
|
||||
Dabei gelten folgende Regeln:
|
||||
|
||||
- Eine virtuelle Schnittstelle kann nur einem Gerät zugeordnet werden.
|
||||
- Ein Gerät kann nicht gleichzeitig mehreren virtuellen Schnittstellen
|
||||
zugeordnet werden.
|
||||
- Eine bereits belegte Schnittstelle kann auf ihre aktuelle Zuordnung
|
||||
oder auf „nicht verbunden“ gesetzt werden.
|
||||
- Freie virtuelle Schnittstellen werden automatisch zur Auswahl
|
||||
angeboten.
|
||||
- Änderungen werden zunächst nur im Arbeitsspeicher gehalten.
|
||||
- Beim Schließen des Gerätezuordnungsfensters werden die Änderungen in
|
||||
`client.json` gespeichert.
|
||||
- Wird das Hauptfenster geschlossen, während ungespeicherte Änderungen
|
||||
vorhanden sind, wird der Anwender darauf hingewiesen.
|
||||
|
||||
## Prüfung der virtuellen seriellen Schnittstellen
|
||||
|
||||
Beim Start des Linux-Clients werden die in `client.json` definierten
|
||||
virtuellen Schnittstellen mit den tatsächlich vorhandenen öffentlichen
|
||||
symbolischen Links verglichen.
|
||||
|
||||
Dabei werden ausschließlich zwei Listen miteinander verglichen:
|
||||
|
||||
```text
|
||||
vorhandene symbolische Links
|
||||
↕
|
||||
definierte virtuelle Ports
|
||||
```
|
||||
|
||||
Es werden insbesondere folgende Abweichungen erkannt:
|
||||
|
||||
- Ein in `client.json` definierter Port besitzt keinen entsprechenden
|
||||
öffentlichen Symlink.
|
||||
- Ein öffentlicher Symlink ist vorhanden, aber nicht in `client.json`
|
||||
eingetragen.
|
||||
|
||||
Bei einer Abweichung wird der Clientstart abgebrochen und dem Anwender
|
||||
ein verständlicher Hinweis angezeigt.
|
||||
|
||||
Der Client legt fehlende öffentliche Links nicht automatisch an.
|
||||
|
||||
Damit bleibt die Systemkonfiguration unter der Kontrolle des Anwenders.
|
||||
|
||||
Die Prüfung selbst verändert keine Systemdateien und benötigt keine
|
||||
Root-Rechte.
|
||||
|
||||
## Manuelle Einrichtung der virtuellen seriellen Ports unter Linux
|
||||
|
||||
Die öffentlichen virtuellen seriellen Ports werden unter Linux einmalig
|
||||
eingerichtet.
|
||||
|
||||
Der laufende `rs2322tcp-client` benötigt anschließend keine Root-Rechte.
|
||||
|
||||
Zunächst wird das Verzeichnis für die internen virtuellen Links angelegt:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.rs2322tcp/virtual
|
||||
```
|
||||
|
||||
Anschließend werden die öffentlichen `/dev/ttyUSBxxx`-Links einmalig
|
||||
mit administrativen Rechten angelegt.
|
||||
|
||||
Beispiel:
|
||||
|
||||
```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:
|
||||
|
||||
```text
|
||||
~/.rs2322tcp/virtual/
|
||||
```
|
||||
|
||||
werden vom Client auf die jeweils verwendeten PTYs gesetzt.
|
||||
|
||||
Die Einrichtung eines neuen virtuellen Ports besteht daher aus:
|
||||
|
||||
1. Einen freien Port aus dem konfigurierten Bereich auswählen.
|
||||
2. Den öffentlichen `/dev/ttyUSBxxx`-Symlink einmalig anlegen.
|
||||
3. Den Port in `client.json` eintragen.
|
||||
4. Den Client starten.
|
||||
5. Die Gerätezuordnung gegebenenfalls über die GUI vornehmen.
|
||||
|
||||
Der Client prüft beim Start, ob die Konfiguration und die vorhandenen
|
||||
Symlinks übereinstimmen.
|
||||
|
||||
Die konkrete Einrichtung ist Linux-spezifisch. Für Windows wird später
|
||||
eine eigene plattformspezifische Lösung verwendet.
|
||||
|
||||
## Virtuelle serielle Schnittstelle unter Linux
|
||||
|
||||
|
|
@ -204,7 +404,9 @@ PTY Slave
|
|||
/dev/pts/N
|
||||
```
|
||||
|
||||
Die externe Hersteller-Software arbeitet mit dem PTY-Slave `/dev/pts/N`.
|
||||
Der PTY-Slave wird über den vom Anwender eingerichteten virtuellen
|
||||
`/dev/ttyUSBxxx`-Symlink erreichbar.
|
||||
|
||||
Der `rs2322tcp-client` verwendet den PTY-Master und transportiert die
|
||||
Bytes über TCP zum Server.
|
||||
|
||||
|
|
@ -214,6 +416,382 @@ vollständigen Bytebereich von `0x00` bis `0xFF` geprüft.
|
|||
Dabei wurde die bidirektionale Übertragung aller 256 möglichen Bytewerte
|
||||
erfolgreich getestet.
|
||||
|
||||
## Vollständiger Simulationsaufbau auf einem Ubuntu-PC
|
||||
|
||||
Der komplette Datenweg kann für Entwicklung und Tests auf einem einzigen
|
||||
Ubuntu-PC ausgeführt werden. Dazu laufen gleichzeitig drei Programme:
|
||||
|
||||
1. `rs2322tcp-pty-test` als simuliertes Rotorgerät
|
||||
2. `rs2322tcp-server` mit `configs/server-test.json`
|
||||
3. `rs2322tcp-client`
|
||||
|
||||
Der Datenweg ist:
|
||||
|
||||
```text
|
||||
Testprogramm oder Hersteller-Software
|
||||
│
|
||||
▼
|
||||
/dev/ttyUSB100
|
||||
│
|
||||
▼
|
||||
rs2322tcp-client
|
||||
│ TCP
|
||||
▼
|
||||
rs2322tcp-server
|
||||
│
|
||||
▼
|
||||
/dev/pts/N
|
||||
│
|
||||
▼
|
||||
rs2322tcp-pty-test
|
||||
simuliertes Rotorgerät
|
||||
```
|
||||
|
||||
### 1. PTY-Simulator starten
|
||||
|
||||
In einem ersten Terminal:
|
||||
|
||||
```bash
|
||||
cd ~/rs2322tcp
|
||||
go run ./cmd/rs2322tcp-pty-test
|
||||
```
|
||||
|
||||
Beispiel:
|
||||
|
||||
```text
|
||||
rs2322tcp-pty-test
|
||||
Port: /dev/pts/1
|
||||
Modus: Rotor-Simulation mit Echo-Fallback
|
||||
Warte auf Daten ...
|
||||
```
|
||||
|
||||
Der ausgegebene PTY-Pfad wird im nächsten Schritt benötigt.
|
||||
|
||||
Der Simulator hält Master und Slave während seiner gesamten Laufzeit geöffnet.
|
||||
Bei der bekannten Rotor-Anfrage wird automatisch die konfigurierte Rotor-Antwort
|
||||
gesendet. Alle anderen Daten werden als Echo zurückgegeben.
|
||||
|
||||
### 2. Testkonfiguration des Servers anpassen
|
||||
|
||||
In `configs/server-test.json` wird der Rotor auf den aktuell vom Simulator
|
||||
ausgegebenen PTY-Slave gesetzt.
|
||||
|
||||
Beispiel:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "rotor",
|
||||
"name": "Antennenrotor",
|
||||
"serial_port": "/dev/pts/1",
|
||||
"baud_rate": 600,
|
||||
"data_bits": 8,
|
||||
"parity": "none",
|
||||
"stop_bits": 1
|
||||
}
|
||||
```
|
||||
|
||||
Die PTY-Nummer ist nicht dauerhaft fest. Nach jedem neuen Start des
|
||||
PTY-Simulators muss der tatsächlich ausgegebene `/dev/pts/N`-Pfad geprüft und
|
||||
gegebenenfalls manuell in `server-test.json` eingetragen werden.
|
||||
|
||||
Im Echtbetrieb wird hier wieder ein fester Hardware-Port verwendet, zum Beispiel:
|
||||
|
||||
```text
|
||||
/dev/ttyUSB0
|
||||
```
|
||||
|
||||
Die serverseitige Zuordnung bleibt bewusst manuell und fest konfiguriert, weil
|
||||
mehrere unterschiedliche Hardwaregeräte gleichzeitig am Server angeschlossen
|
||||
sein können.
|
||||
|
||||
### 3. Server starten
|
||||
|
||||
In einem zweiten Terminal:
|
||||
|
||||
```bash
|
||||
cd ~/rs2322tcp
|
||||
go run ./cmd/rs2322tcp-server -config ./configs/server-test.json
|
||||
```
|
||||
|
||||
Beispiel:
|
||||
|
||||
```text
|
||||
rs2322tcp-server dev
|
||||
Control-Server gestartet auf [::]:5000
|
||||
Geräte: 3
|
||||
```
|
||||
|
||||
### 4. Server-Monitor als Sniffer
|
||||
|
||||
Ist in der Server-Konfiguration
|
||||
|
||||
```json
|
||||
"serial_monitor": true
|
||||
```
|
||||
|
||||
gesetzt, protokolliert der Server den übertragenen Datenverkehr. Im
|
||||
Simulationsaufbau werden dadurch unter anderem die Richtungen
|
||||
|
||||
```text
|
||||
TCP RX
|
||||
SERIAL TX
|
||||
SERIAL RX
|
||||
TCP TX
|
||||
```
|
||||
|
||||
sichtbar.
|
||||
|
||||
Ein typischer Ablauf ist:
|
||||
|
||||
```text
|
||||
TCP RX 57 00 00 00 00 00 00 00 00 00 00 1F 20
|
||||
SERIAL TX 57 00 00 00 00 00 00 00 00 00 00 1F 20
|
||||
SERIAL RX 57 07 01 06 00 01 03 06 02 00 01 20
|
||||
TCP TX 57 07 01 06 00 01 03 06 02 00 01 20
|
||||
```
|
||||
|
||||
Der Server arbeitet damit gleichzeitig als Monitor beziehungsweise Sniffer für
|
||||
den übertragenen seriellen Datenverkehr.
|
||||
|
||||
### 5. Client-Konfiguration für den lokalen Test
|
||||
|
||||
Für den lokalen Test kann `configs/client.json` beispielsweise so aussehen:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"address": "127.0.0.1",
|
||||
"port": 5000
|
||||
},
|
||||
"virtual_port_range": {
|
||||
"first": 100,
|
||||
"last": 199
|
||||
},
|
||||
"virtual_ports": [
|
||||
{
|
||||
"port": "/dev/ttyUSB100",
|
||||
"remote_device": "rotor",
|
||||
"startup_bytes": "57 00 00 00 00 00 00 00 00 00 00 1F 20"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Der öffentliche virtuelle Port `/dev/ttyUSB100` muss vorher wie im Abschnitt
|
||||
zur manuellen Einrichtung der virtuellen Ports einmalig angelegt worden sein.
|
||||
|
||||
### 6. Client starten
|
||||
|
||||
In einem dritten Terminal:
|
||||
|
||||
```bash
|
||||
cd ~/rs2322tcp
|
||||
go run ./cmd/rs2322tcp-client
|
||||
```
|
||||
|
||||
Nach erfolgreichem Verbindungsaufbau werden die konfigurierten `startup_bytes`
|
||||
automatisch zum zugeordneten Rotor übertragen.
|
||||
|
||||
Der Server-Monitor muss dann den vollständigen Ablauf
|
||||
|
||||
```text
|
||||
TCP RX -> SERIAL TX -> SERIAL RX -> TCP TX
|
||||
```
|
||||
|
||||
zeigen.
|
||||
|
||||
Damit ist der gesamte Datenweg vom virtuellen Client-Port über TCP zum
|
||||
simulierten Rotor und wieder zurück geprüft.
|
||||
|
||||
### 7. Virtuellen Port direkt testen
|
||||
|
||||
Eine Antwort kann beispielsweise mit
|
||||
|
||||
```bash
|
||||
cat /dev/ttyUSB100 | xxd -g 1
|
||||
```
|
||||
|
||||
beobachtet werden.
|
||||
|
||||
In einem zweiten Terminal kann eine Rotor-Anfrage gesendet werden:
|
||||
|
||||
```bash
|
||||
printf '\x57\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1f\x20' > /dev/ttyUSB100
|
||||
```
|
||||
|
||||
Der Rückweg erfolgt über denselben vollständigen Datenpfad zurück zu
|
||||
`/dev/ttyUSB100`.
|
||||
|
||||
### Hinweis zu `/dev/pts/N`
|
||||
|
||||
`/dev/pts/N` wird in diesem Aufbau ausschließlich als temporärer Test-Port
|
||||
für den PTY-Simulator verwendet. Die Nummer ist nicht dauerhaft und kann sich
|
||||
nach einem Neustart des Simulators ändern.
|
||||
|
||||
Im produktiven Betrieb sind die serverseitigen Geräte dagegen echte,
|
||||
fest angeschlossene Hardware-Schnittstellen, beispielsweise:
|
||||
|
||||
```text
|
||||
/dev/ttyUSB0
|
||||
/dev/ttyUSB1
|
||||
/dev/ttyUSB3
|
||||
```
|
||||
|
||||
## Diagnose und Logging
|
||||
|
||||
Für Debugging und Diagnose soll eine Protokollierung des übertragenen
|
||||
Datenverkehrs möglich sein.
|
||||
|
||||
Insbesondere soll eine Darstellung der übertragenen Bytes möglich sein,
|
||||
um Probleme bei der Kommunikation zwischen Hersteller-Software und Gerät
|
||||
analysieren zu können.
|
||||
|
||||
Für die Linux-PTY-Funktion und den beschriebenen Simulationsaufbau wird
|
||||
`rs2322tcp` ohne `socat` betrieben. Die benötigten PTYs werden direkt aus Go
|
||||
erzeugt.
|
||||
|
||||
## 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 beispielsweise 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.
|
||||
|
||||
## Geräte-Simulator
|
||||
|
||||
Das Programm `rs2322tcp-device-sim` dient als Simulator für ein an der
|
||||
|
|
@ -272,13 +850,13 @@ Auch Groß- und Kleinschreibung kann gemischt werden:
|
|||
0a FF 01 b7
|
||||
```
|
||||
|
||||
Ungültige Eingaben wie
|
||||
Ungültige Eingaben wie:
|
||||
|
||||
```text
|
||||
GG
|
||||
```
|
||||
|
||||
oder
|
||||
oder:
|
||||
|
||||
```text
|
||||
4
|
||||
|
|
@ -305,8 +883,8 @@ Für die Entwicklung existiert zusätzlich das separate Testprogramm:
|
|||
~/rs2322tcp-pty-test
|
||||
```
|
||||
|
||||
Es erzeugt unter Linux ein PTY, setzt den Slave in den Raw Mode und zeigt
|
||||
den Namen des Slave-Gerätes an.
|
||||
Es erzeugt unter Linux ein PTY, setzt den Slave in den Raw Mode und stellt
|
||||
eine simulierte serielle Gegenstelle bereit.
|
||||
|
||||
Beispiel:
|
||||
|
||||
|
|
@ -354,8 +932,7 @@ empfangenen Bytes lediglich an.
|
|||
|
||||
## Externe USB-to-Seriell-Adapter
|
||||
|
||||
Für den späteren praktischen Einsatz sind USB-to-Seriell-Adapter
|
||||
vorgesehen.
|
||||
Für den praktischen Einsatz sind USB-to-Seriell-Adapter vorgesehen.
|
||||
|
||||
Unter Linux werden solche Adapter normalerweise als serielle Gerätedateien
|
||||
bereitgestellt, beispielsweise:
|
||||
|
|
@ -392,12 +969,28 @@ go test -race ./...
|
|||
```
|
||||
|
||||
Der Race Detector wird eingesetzt, um Probleme bei der nebenläufigen
|
||||
Verarbeitung von Sessions, Data-Listenern, DataConnections und Client-
|
||||
Komponenten zu erkennen.
|
||||
Verarbeitung von Sessions, Data-Listenern, DataConnections und
|
||||
Client-Komponenten zu erkennen.
|
||||
|
||||
Die Linux-PTY-Komponente wurde mit einem vollständigen Bytebereich von
|
||||
`0x00` bis `0xFF` geprüft.
|
||||
|
||||
Die Verwaltung der virtuellen Ports verfügt über Tests für:
|
||||
|
||||
- automatische Portreservierung
|
||||
- gezielte Reservierung einer konfigurierten Portnummer
|
||||
- Freigabe virtueller Ports
|
||||
- Erstellung und Entfernung der internen PTY-Links
|
||||
- Abgleich vorhandener virtueller Ports mit der Client-Konfiguration
|
||||
|
||||
Die Gerätezuordnung verfügt über Tests für:
|
||||
|
||||
- Zuordnung von Geräten zu virtuellen Ports
|
||||
- Freigabe virtueller Ports
|
||||
- Vermeidung doppelter Zuordnungen
|
||||
- Speicherung der Konfiguration
|
||||
- Laden der gespeicherten Zuordnung
|
||||
|
||||
Der Geräte-Simulator verfügt zusätzlich über automatisierte Tests für
|
||||
die Hex-Konvertierung und die Behandlung ungültiger Eingaben.
|
||||
|
||||
|
|
@ -413,8 +1006,11 @@ git status
|
|||
```text
|
||||
rs2322tcp/
|
||||
├── cmd/
|
||||
│ ├── com0com-test/
|
||||
│ ├── rs2322tcp-client/
|
||||
│ ├── rs2322tcp-client-test/
|
||||
│ ├── rs2322tcp-device-sim/
|
||||
│ ├── rs2322tcp-pty-test/
|
||||
│ └── rs2322tcp-server/
|
||||
├── configs/
|
||||
├── docs/
|
||||
|
|
@ -458,6 +1054,9 @@ Aktuell werden folgende Builds erzeugt:
|
|||
- Linux armv7 -- Server
|
||||
- Linux armv6 -- Server
|
||||
|
||||
Die gemeinsame Client-Architektur ist für eine spätere
|
||||
Windows-Unterstützung vorbereitet.
|
||||
|
||||
## Versionierung
|
||||
|
||||
Die Versionierung erfolgt über Git.
|
||||
|
|
@ -468,17 +1067,20 @@ Build-Datum erfasst.
|
|||
|
||||
Release-Versionen werden über Git-Tags gekennzeichnet.
|
||||
|
||||
Aktueller Stand:
|
||||
Aktueller Entwicklungsstand:
|
||||
|
||||
- `v0.0.1` – erste Projektversion
|
||||
- `v0.0.1` – Projektgrundlage und Buildsystem
|
||||
- `v0.0.2` – Client-Control- und Data-Verbindungen
|
||||
- `v0.0.3` – Linux-PTY-Unterstützung und Geräte-Simulator
|
||||
- `v0.0.4` – in Vorbereitung: interaktive TX-Funktion und automatisierte
|
||||
Tests des Geräte-Simulators
|
||||
- `v0.0.4` – interaktive TX-Funktion des Geräte-Simulators
|
||||
- `v0.0.5` – konfigurierbare lokale virtuelle serielle Ports
|
||||
- `v0.0.6` – Client-Runtime und Serial-Bridge
|
||||
- `0.0.7` – Geräteerkennung und grafische Gerätezuordnung
|
||||
- `0.0.8` – Validierung der virtuellen Ports und Überarbeitung der Client-GUI
|
||||
|
||||
## Entwicklungsstand
|
||||
|
||||
Das Projekt befindet sich weiterhin in der frühen Entwicklungsphase.
|
||||
Das Projekt befindet sich weiterhin in der Entwicklungsphase.
|
||||
|
||||
Implementiert und getestet sind derzeit:
|
||||
|
||||
|
|
@ -491,7 +1093,8 @@ Implementiert und getestet sind derzeit:
|
|||
- Konfiguration der seriellen Parameter
|
||||
- Client-Control-Verbindung
|
||||
- Client-DataConnection
|
||||
- Client-Integrationstests
|
||||
- Client-Runtime zur Verwaltung mehrerer virtueller Ports
|
||||
- gezielte Zuordnung konfigurierter virtueller Portnummern
|
||||
- Linux-PTY-Abstraktion
|
||||
- Linux-PTY im Raw Mode
|
||||
- automatisierter bidirektionaler 256-Byte-PTY-Test
|
||||
|
|
@ -500,13 +1103,22 @@ Implementiert und getestet sind derzeit:
|
|||
- eigenständiger Geräte-Simulator mit RX-Hexdump
|
||||
- interaktive TX-Hex-Eingabe des Geräte-Simulators
|
||||
- automatisierte Tests der Hex-Konvertierung
|
||||
- grafische Gerätezuordnung im Client
|
||||
- Speicherung der Gerätezuordnung in `client.json`
|
||||
- Laden der Gerätezuordnung beim Neustart
|
||||
- Validierung der vorhandenen virtuellen Ports beim Clientstart
|
||||
- Abgleich von vorhandenen Symlinks und konfigurierten virtuellen Ports
|
||||
- Linux-Portverwaltung ohne Root-Rechte im laufenden Client
|
||||
- verständlicher Fehlerdialog bei fehlerhafter Portkonfiguration
|
||||
- anpassbare allgemeine Schriftgröße der GUI
|
||||
- überarbeitete Fenstergrößen der GUI
|
||||
|
||||
Noch nicht vollständig implementiert sind insbesondere:
|
||||
|
||||
- die vollständige Anbindung der virtuellen seriellen Schnittstelle an
|
||||
den produktiven Client-Anwendungsdatenpfad
|
||||
- die Windows-Implementierung der virtuellen seriellen Schnittstelle
|
||||
- automatische Geräteantworten und Protokollsimulation
|
||||
- weitere produktive Funktionen des Clients und der GUI
|
||||
- weiterführende Diagnose- und Logging-Funktionen
|
||||
|
||||
## Lizenz
|
||||
|
||||
|
|
|
|||
239
cmd/com0com-test/main.go
Normal file
239
cmd/com0com-test/main.go
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
//go:build windows
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: cmd/com0com-test/main.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Direkter Test eines com0com-Portpaares.
|
||||
*
|
||||
* Der Test öffnet COM100 und COM10 gleichzeitig und überprüft die
|
||||
* Datenübertragung in beide Richtungen:
|
||||
*
|
||||
* COM100 -> COM10
|
||||
* COM10 -> COM100
|
||||
*
|
||||
* Dadurch kann die Funktion von com0com und go.bug.st/serial unabhängig
|
||||
* vom rs2322tcp-Client, TCP-Server und PTY-Testaufbau geprüft werden.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("rs2322tcp com0com Direkt-Test")
|
||||
fmt.Println()
|
||||
|
||||
mode := &serial.Mode{
|
||||
BaudRate: 600,
|
||||
DataBits: 8,
|
||||
Parity: serial.NoParity,
|
||||
StopBits: serial.OneStopBit,
|
||||
}
|
||||
|
||||
com10, err := serial.Open("COM10", mode)
|
||||
if err != nil {
|
||||
log.Fatalf("COM10 öffnen: %v", err)
|
||||
}
|
||||
defer com10.Close()
|
||||
|
||||
fmt.Println("COM10 geöffnet.")
|
||||
|
||||
com100, err := serial.Open("COM100", mode)
|
||||
if err != nil {
|
||||
log.Fatalf("COM100 öffnen: %v", err)
|
||||
}
|
||||
defer com100.Close()
|
||||
|
||||
fmt.Println("COM100 geöffnet.")
|
||||
fmt.Println()
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Test 1: COM100 -> COM10
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
request := []byte{
|
||||
0x57,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x1F,
|
||||
0x20,
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"COM100 TX: %d Bytes\n",
|
||||
len(request),
|
||||
)
|
||||
printHex(request)
|
||||
|
||||
n, err := com100.Write(request)
|
||||
if err != nil {
|
||||
log.Fatalf("COM100 schreiben: %v", err)
|
||||
}
|
||||
|
||||
if n != len(request) {
|
||||
log.Fatalf(
|
||||
"COM100 schreiben: %d von %d Bytes geschrieben",
|
||||
n,
|
||||
len(request),
|
||||
)
|
||||
}
|
||||
|
||||
fmt.Println("COM100 schreiben erfolgreich.")
|
||||
|
||||
received := make([]byte, len(request))
|
||||
|
||||
if err := com10.SetReadTimeout(2 * time.Second); err != nil {
|
||||
log.Fatalf("COM10 ReadTimeout setzen: %v", err)
|
||||
}
|
||||
|
||||
n, err = com10.Read(received)
|
||||
if err != nil {
|
||||
log.Fatalf("COM10 lesen: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"COM10 RX: %d Bytes\n",
|
||||
n,
|
||||
)
|
||||
printHex(received[:n])
|
||||
|
||||
if n != len(request) {
|
||||
log.Fatalf(
|
||||
"COM10: %d Bytes empfangen, erwartet %d",
|
||||
n,
|
||||
len(request),
|
||||
)
|
||||
}
|
||||
|
||||
if !equalBytes(request, received[:n]) {
|
||||
log.Fatal("COM10: empfangene Daten stimmen nicht mit den gesendeten Daten überein")
|
||||
}
|
||||
|
||||
fmt.Println("Test COM100 -> COM10 erfolgreich.")
|
||||
fmt.Println()
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Test 2: COM10 -> COM100
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
response := []byte{
|
||||
0x57,
|
||||
0x07,
|
||||
0x01,
|
||||
0x06,
|
||||
0x00,
|
||||
0x01,
|
||||
0x03,
|
||||
0x06,
|
||||
0x02,
|
||||
0x00,
|
||||
0x01,
|
||||
0x20,
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"COM10 TX: %d Bytes\n",
|
||||
len(response),
|
||||
)
|
||||
printHex(response)
|
||||
|
||||
n, err = com10.Write(response)
|
||||
if err != nil {
|
||||
log.Fatalf("COM10 schreiben: %v", err)
|
||||
}
|
||||
|
||||
if n != len(response) {
|
||||
log.Fatalf(
|
||||
"COM10 schreiben: %d von %d Bytes geschrieben",
|
||||
n,
|
||||
len(response),
|
||||
)
|
||||
}
|
||||
|
||||
fmt.Println("COM10 schreiben erfolgreich.")
|
||||
|
||||
received = make([]byte, len(response))
|
||||
|
||||
if err := com100.SetReadTimeout(2 * time.Second); err != nil {
|
||||
log.Fatalf("COM100 ReadTimeout setzen: %v", err)
|
||||
}
|
||||
|
||||
n, err = com100.Read(received)
|
||||
if err != nil {
|
||||
log.Fatalf("COM100 lesen: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"COM100 RX: %d Bytes\n",
|
||||
n,
|
||||
)
|
||||
printHex(received[:n])
|
||||
|
||||
if n != len(response) {
|
||||
log.Fatalf(
|
||||
"COM100: %d Bytes empfangen, erwartet %d",
|
||||
n,
|
||||
len(response),
|
||||
)
|
||||
}
|
||||
|
||||
if !equalBytes(response, received[:n]) {
|
||||
log.Fatal("COM100: empfangene Daten stimmen nicht mit den gesendeten Daten überein")
|
||||
}
|
||||
|
||||
fmt.Println("Test COM10 -> COM100 erfolgreich.")
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("==========================================")
|
||||
fmt.Println("Alle com0com-Direkt-Tests erfolgreich.")
|
||||
fmt.Println("==========================================")
|
||||
}
|
||||
|
||||
// printHex gibt einen Byte-Block hexadezimal aus.
|
||||
func printHex(data []byte) {
|
||||
for i, b := range data {
|
||||
if i > 0 {
|
||||
fmt.Print(" ")
|
||||
}
|
||||
|
||||
fmt.Printf("%02X", b)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// equalBytes compares two byte slices.
|
||||
func equalBytes(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
44
cmd/rs2322tcp-client-test/main.go
Normal file
44
cmd/rs2322tcp-client-test/main.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/client"
|
||||
)
|
||||
|
||||
func main() {
|
||||
const configFile = "./configs/client.json"
|
||||
|
||||
application, err := client.NewApplication(configFile)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "NewApplication: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := application.Start(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Application.Start: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("rs2322tcp technical client started")
|
||||
fmt.Println("Press Ctrl+C to stop.")
|
||||
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(
|
||||
signals,
|
||||
os.Interrupt,
|
||||
syscall.SIGTERM,
|
||||
)
|
||||
|
||||
<-signals
|
||||
|
||||
fmt.Println("Stopping client...")
|
||||
|
||||
if err := application.Close(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Application.Close: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,140 @@
|
|||
/*
|
||||
Package main implements the rs2322tcp client application.
|
||||
|
||||
The client will provide a local virtual serial interface and
|
||||
transparently transport serial data over TCP to the rs2322tcp server.
|
||||
|
||||
At this stage the application only provides the basic program
|
||||
structure and version information.
|
||||
|
||||
Project: rs2322tcp
|
||||
Module: git.lang-dieter.de/rs2322tcp
|
||||
*/
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: cmd/rs2322tcp-client/main.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Einstiegspunkt für den grafischen rs2322tcp-Client.
|
||||
*
|
||||
* Der Client wird als eigenständige Desktop-Anwendung ausgeführt. Die
|
||||
* grafische Oberfläche basiert auf Fyne und bildet sowohl die Statusanzeige
|
||||
* als auch später die Konfiguration der lokalen virtuellen seriellen
|
||||
* Ports ab.
|
||||
*
|
||||
* Die eigentliche Client- und Runtime-Logik bleibt in den internen
|
||||
* Packages gekapselt. Diese Datei ist bewusst auf den Programmstart
|
||||
* und die Übergabe an die GUI beschränkt.
|
||||
* ============================================================================
|
||||
*/
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/gui"
|
||||
"git.lang-dieter.de/rs2322tcp/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Printf("rs2322tcp-client %s\n", version.Version)
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Application theme
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// applicationTheme keeps the standard Fyne theme and increases the general
|
||||
// text size for better readability.
|
||||
//
|
||||
// All other colors, fonts, icons and dimensions remain unchanged.
|
||||
type applicationTheme struct{}
|
||||
|
||||
// Ensure applicationTheme implements fyne.Theme.
|
||||
var _ fyne.Theme = (*applicationTheme)(nil)
|
||||
|
||||
// Color returns the standard Fyne theme color.
|
||||
func (t *applicationTheme) Color(
|
||||
name fyne.ThemeColorName,
|
||||
variant fyne.ThemeVariant,
|
||||
) color.Color {
|
||||
return theme.DefaultTheme().Color(name, variant)
|
||||
}
|
||||
|
||||
// Font returns the standard Fyne theme font.
|
||||
func (t *applicationTheme) Font(
|
||||
style fyne.TextStyle,
|
||||
) fyne.Resource {
|
||||
return theme.DefaultTheme().Font(style)
|
||||
}
|
||||
|
||||
// Icon returns the standard Fyne theme icon.
|
||||
func (t *applicationTheme) Icon(
|
||||
name fyne.ThemeIconName,
|
||||
) fyne.Resource {
|
||||
return theme.DefaultTheme().Icon(name)
|
||||
}
|
||||
|
||||
// Size returns the standard Fyne theme size, except for normal text.
|
||||
//
|
||||
// Normal text is increased from the Fyne default to 22 px for better
|
||||
// readability.
|
||||
func (t *applicationTheme) Size(
|
||||
name fyne.ThemeSizeName,
|
||||
) float32 {
|
||||
if name == theme.SizeNameText {
|
||||
return 22
|
||||
}
|
||||
|
||||
return theme.DefaultTheme().Size(name)
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Main
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func main() {
|
||||
fmt.Printf(
|
||||
"rs2322tcp-client %s\n",
|
||||
version.Version,
|
||||
)
|
||||
|
||||
application := app.NewWithID(
|
||||
"git.lang-dieter.de.rs2322tcp.client",
|
||||
)
|
||||
|
||||
// Use the standard Fyne theme with a larger general text size.
|
||||
application.Settings().SetTheme(
|
||||
&applicationTheme{},
|
||||
)
|
||||
|
||||
guiApplication, err := gui.NewApp(
|
||||
application,
|
||||
"./configs/client.json",
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf(
|
||||
"GUI konnte nicht gestartet werden: %v\n",
|
||||
err,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// SetOnClosed gehört zum Fyne-Fenster, nicht zur Fyne-Anwendung.
|
||||
//
|
||||
// Beim Schließen des Hauptfensters wird die technische Client-
|
||||
// Anwendung beendet. Dadurch werden Runtime, Bridges, virtuelle
|
||||
// Ports und die Serververbindung sauber geschlossen.
|
||||
guiApplication.Window().SetOnClosed(
|
||||
func() {
|
||||
guiApplication.Close()
|
||||
},
|
||||
)
|
||||
|
||||
// Zuerst das GUI-Fenster erzeugen und anzeigen.
|
||||
//
|
||||
// Dadurch wird ein möglicher Fehler beim Start von Fyne/GLFW erkannt,
|
||||
// bevor die technische Client-Kommunikation gestartet wird.
|
||||
guiApplication.Window().Show()
|
||||
|
||||
// Erst nachdem das Fenster erfolgreich erzeugt wurde, wird das
|
||||
// technische Client-System asynchron gestartet.
|
||||
guiApplication.Start()
|
||||
|
||||
// Den Fyne-Eventloop starten.
|
||||
application.Run()
|
||||
}
|
||||
348
cmd/rs2322tcp-pty-test/main.go
Normal file
348
cmd/rs2322tcp-pty-test/main.go
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
//go:build linux
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: cmd/rs2322tcp-pty-test/main.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Entwicklungs- und Testwerkzeug zum Bereitstellen eines einzelnen
|
||||
* bidirektionalen Linux-PTYs als simuliertes serielles Endgerät.
|
||||
*
|
||||
* Der erzeugte PTY-Slave wird vom rs2322tcp-server wie eine echte serielle
|
||||
* Schnittstelle verwendet. Der Helper arbeitet auf dem zugehörigen
|
||||
* PTY-Master als Gegenstelle.
|
||||
*
|
||||
* Alle vom Server empfangenen Daten werden als RX-Hexdump ausgegeben.
|
||||
*
|
||||
* Bei der bekannten Rotor-Anfrage wird automatisch die konfigurierte
|
||||
* Rotor-Antwort zurückgesendet. Alle anderen empfangenen Daten werden
|
||||
* unverändert als Echo zurückgesendet.
|
||||
*
|
||||
* Der PTY-Slave bleibt während der gesamten Laufzeit des Helpers geöffnet.
|
||||
* Dadurch bleibt die virtuelle serielle Gegenstelle auch dann verfügbar,
|
||||
* wenn der Server den Port erst nach dem Start des Helpers öffnet.
|
||||
*
|
||||
* Beim Beenden werden Master und Slave geschlossen. Der Test-Port
|
||||
* verschwindet anschließend wieder aus /dev/pts.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var (
|
||||
rotorRequest = []byte{
|
||||
0x57,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x1F,
|
||||
0x20,
|
||||
}
|
||||
|
||||
rotorResponse = []byte{
|
||||
0x57,
|
||||
0x07,
|
||||
0x01,
|
||||
0x06,
|
||||
0x00,
|
||||
0x01,
|
||||
0x03,
|
||||
0x06,
|
||||
0x02,
|
||||
0x00,
|
||||
0x01,
|
||||
0x20,
|
||||
}
|
||||
)
|
||||
|
||||
func main() {
|
||||
masterFD, slaveFD, slavePath, err := createPTY()
|
||||
if err != nil {
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
"PTY-Erzeugung fehlgeschlagen: %v\n",
|
||||
err,
|
||||
)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
defer unix.Close(masterFD)
|
||||
defer unix.Close(slaveFD)
|
||||
|
||||
fmt.Println("rs2322tcp-pty-test")
|
||||
fmt.Printf("Port: %s\n", slavePath)
|
||||
fmt.Println("Modus: Rotor-Simulation mit Echo-Fallback")
|
||||
fmt.Println("Warte auf Daten ...")
|
||||
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(
|
||||
signals,
|
||||
os.Interrupt,
|
||||
syscall.SIGTERM,
|
||||
)
|
||||
defer signal.Stop(signals)
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
handleSerial(masterFD)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-signals:
|
||||
fmt.Println()
|
||||
fmt.Println("PTY-Testport wird geschlossen.")
|
||||
|
||||
case <-done:
|
||||
fmt.Println()
|
||||
fmt.Println("PTY-Gegenstelle wurde geschlossen.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleSerial(fd int) {
|
||||
buf := make([]byte, 4096)
|
||||
|
||||
for {
|
||||
n, err := unix.Read(fd, buf)
|
||||
if err != nil {
|
||||
if err == unix.EINTR {
|
||||
continue
|
||||
}
|
||||
|
||||
if err == unix.EIO || err == io.EOF {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
"Lesen vom PTY-Master: %v\n",
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
received := append([]byte(nil), buf[:n]...)
|
||||
|
||||
printHexDump("RX", received)
|
||||
|
||||
response := responseFor(received)
|
||||
|
||||
if err := writeAll(fd, response); err != nil {
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
"Schreiben zum PTY-Master: %v\n",
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
printHexDump("TX", response)
|
||||
}
|
||||
}
|
||||
|
||||
func responseFor(received []byte) []byte {
|
||||
if bytes.Equal(received, rotorRequest) {
|
||||
return rotorResponse
|
||||
}
|
||||
|
||||
return received
|
||||
}
|
||||
|
||||
func createPTY() (int, int, string, error) {
|
||||
masterFD, err := unix.Open(
|
||||
"/dev/ptmx",
|
||||
unix.O_RDWR|unix.O_NOCTTY,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return -1, -1, "", fmt.Errorf(
|
||||
"open PTY master: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
closeMasterOnError := true
|
||||
defer func() {
|
||||
if closeMasterOnError {
|
||||
_ = unix.Close(masterFD)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := unix.IoctlSetPointerInt(
|
||||
masterFD,
|
||||
unix.TIOCSPTLCK,
|
||||
0,
|
||||
); err != nil {
|
||||
return -1, -1, "", fmt.Errorf(
|
||||
"unlock PTY slave: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
ptsNumber, err := unix.IoctlGetInt(
|
||||
masterFD,
|
||||
unix.TIOCGPTN,
|
||||
)
|
||||
if err != nil {
|
||||
return -1, -1, "", fmt.Errorf(
|
||||
"get PTY number: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
slavePath := fmt.Sprintf(
|
||||
"/dev/pts/%d",
|
||||
ptsNumber,
|
||||
)
|
||||
|
||||
slaveFD, err := unix.Open(
|
||||
slavePath,
|
||||
unix.O_RDWR|unix.O_NOCTTY,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return -1, -1, "", fmt.Errorf(
|
||||
"open PTY slave %s: %w",
|
||||
slavePath,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
closeSlaveOnError := true
|
||||
defer func() {
|
||||
if closeSlaveOnError {
|
||||
_ = unix.Close(slaveFD)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := configurePTY(slaveFD); err != nil {
|
||||
return -1, -1, "", err
|
||||
}
|
||||
|
||||
closeMasterOnError = false
|
||||
closeSlaveOnError = false
|
||||
|
||||
return masterFD, slaveFD, slavePath, nil
|
||||
}
|
||||
|
||||
func configurePTY(fd int) error {
|
||||
termios, err := unix.IoctlGetTermios(
|
||||
fd,
|
||||
unix.TCGETS,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"get PTY termios: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
termios.Iflag &^= unix.IGNBRK |
|
||||
unix.BRKINT |
|
||||
unix.PARMRK |
|
||||
unix.ISTRIP |
|
||||
unix.INLCR |
|
||||
unix.IGNCR |
|
||||
unix.ICRNL |
|
||||
unix.IXON
|
||||
|
||||
termios.Oflag &^= unix.OPOST
|
||||
|
||||
termios.Lflag &^= unix.ECHO |
|
||||
unix.ECHONL |
|
||||
unix.ICANON |
|
||||
unix.ISIG |
|
||||
unix.IEXTEN
|
||||
|
||||
termios.Cflag &^= unix.CSIZE | unix.PARENB
|
||||
termios.Cflag |= unix.CS8
|
||||
|
||||
termios.Cc[unix.VMIN] = 1
|
||||
termios.Cc[unix.VTIME] = 0
|
||||
|
||||
if err := unix.IoctlSetTermios(
|
||||
fd,
|
||||
unix.TCSETS,
|
||||
termios,
|
||||
); err != nil {
|
||||
return fmt.Errorf(
|
||||
"set PTY raw mode: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeAll(fd int, data []byte) error {
|
||||
for len(data) > 0 {
|
||||
n, err := unix.Write(fd, data)
|
||||
if err != nil {
|
||||
if err == unix.EINTR {
|
||||
continue
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
|
||||
data = data[n:]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printHexDump(direction string, data []byte) {
|
||||
fmt.Printf(
|
||||
"%s %s %d Bytes\n",
|
||||
time.Now().Format("15:04:05.000"),
|
||||
direction,
|
||||
len(data),
|
||||
)
|
||||
|
||||
for offset := 0; offset < len(data); offset += 16 {
|
||||
end := offset + 16
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
|
||||
fmt.Print(" ")
|
||||
|
||||
for _, b := range data[offset:end] {
|
||||
fmt.Printf("%02X ", b)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,9 @@
|
|||
/*
|
||||
Package main implements the rs2322tcp server application.
|
||||
|
||||
The server will provide the network endpoint for rs2322tcp clients
|
||||
and connect incoming connections to configured physical RS232
|
||||
interfaces.
|
||||
|
||||
At this stage the application only provides the basic program
|
||||
structure and version information.
|
||||
The server provides the network endpoint for rs2322tcp clients
|
||||
and connects incoming data connections to configured physical
|
||||
RS232 interfaces.
|
||||
|
||||
Project: rs2322tcp
|
||||
Module: git.lang-dieter.de/rs2322tcp
|
||||
|
|
@ -14,11 +11,56 @@ Module: git.lang-dieter.de/rs2322tcp
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
"git.lang-dieter.de/rs2322tcp/internal/server"
|
||||
"git.lang-dieter.de/rs2322tcp/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Printf("rs2322tcp-server %s\n", version.Version)
|
||||
configFile := flag.String(
|
||||
"config",
|
||||
"./configs/server.json",
|
||||
"Pfad zur Server-Konfigurationsdatei",
|
||||
)
|
||||
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.LoadServer(*configFile)
|
||||
if err != nil {
|
||||
log.Fatalf("Server-Konfiguration laden: %v", err)
|
||||
}
|
||||
|
||||
controlServer, err := server.NewControlServer(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Control-Server erstellen: %v", err)
|
||||
}
|
||||
|
||||
if err := controlServer.Listen(); err != nil {
|
||||
log.Fatalf("Control-Server starten: %v", err)
|
||||
}
|
||||
|
||||
defer controlServer.Close()
|
||||
|
||||
fmt.Printf(
|
||||
"rs2322tcp-server %s\n",
|
||||
version.Version,
|
||||
)
|
||||
|
||||
fmt.Printf(
|
||||
"Control-Server gestartet auf %s\n",
|
||||
controlServer.Addr(),
|
||||
)
|
||||
|
||||
fmt.Printf(
|
||||
"Geräte: %d\n",
|
||||
len(cfg.Devices),
|
||||
)
|
||||
|
||||
if err := controlServer.Serve(); err != nil {
|
||||
log.Fatalf("Control-Server beendet: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"server": {
|
||||
"address": "100.64.0.10",
|
||||
"address": "127.0.0.1",
|
||||
"port": 5000
|
||||
},
|
||||
"virtual_port_range": {
|
||||
|
|
@ -10,11 +10,9 @@
|
|||
"virtual_ports": [
|
||||
{
|
||||
"port": "/dev/ttyUSB100",
|
||||
"remote_device": "radio"
|
||||
},
|
||||
{
|
||||
"port": "/dev/ttyUSB101",
|
||||
"remote_device": "rotor"
|
||||
"remote_device": "rotor",
|
||||
"startup_bytes": "57 00 00 00 00 00 00 00 00 00 00 1F 20"
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
|
|
|
|||
41
configs/server-test.json
Normal file
41
configs/server-test.json
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"listen": {
|
||||
"address": "0.0.0.0",
|
||||
"port": 5000
|
||||
},
|
||||
|
||||
"hardware_error_response": "ERROR - HARDWARE NOT AVAILABLE",
|
||||
|
||||
"serial_monitor": false,
|
||||
|
||||
"devices": [
|
||||
{
|
||||
"id": "radio",
|
||||
"name": "Funkgerät",
|
||||
"serial_port": "/dev/ttyUSB1",
|
||||
"baud_rate": 9600,
|
||||
"data_bits": 8,
|
||||
"parity": "none",
|
||||
"stop_bits": 1
|
||||
},
|
||||
{
|
||||
"id": "rotor",
|
||||
"name": "Antennenrotor",
|
||||
"serial_port": "/dev/pts/1",
|
||||
"baud_rate": 600,
|
||||
"data_bits": 8,
|
||||
"parity": "none",
|
||||
"stop_bits": 1
|
||||
},
|
||||
{
|
||||
"id": "gps",
|
||||
"name": "GPS-Testgerät",
|
||||
"serial_port": "/dev/ttyUSB3",
|
||||
"baud_rate": 600,
|
||||
"data_bits": 8,
|
||||
"parity": "none",
|
||||
"stop_bits": 1
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
|
|
@ -3,11 +3,16 @@
|
|||
"address": "0.0.0.0",
|
||||
"port": 5000
|
||||
},
|
||||
|
||||
"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",
|
||||
|
|
@ -16,11 +21,21 @@
|
|||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"id": "gps",
|
||||
"name": "GPS-Testgerät",
|
||||
"serial_port": "/dev/ttyUSB3",
|
||||
"baud_rate": 600,
|
||||
"data_bits": 8,
|
||||
"parity": "none",
|
||||
"stop_bits": 1
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
|
|
|
|||
44
go.mod
44
go.mod
|
|
@ -2,6 +2,46 @@ module git.lang-dieter.de/rs2322tcp
|
|||
|
||||
go 1.25.0
|
||||
|
||||
require go.bug.st/serial v1.7.1
|
||||
require (
|
||||
go.bug.st/serial v1.7.1
|
||||
golang.org/x/sys v0.43.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.43.0 // indirect
|
||||
require github.com/FyshOS/fancyfs v0.0.1 // indirect
|
||||
|
||||
require (
|
||||
fyne.io/fyne/v2 v2.8.0
|
||||
fyne.io/systray v1.12.2 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/anthonynsimon/bild v0.14.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/fredbi/uri v1.1.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect
|
||||
github.com/fyne-io/glfw-js v0.4.0 // indirect
|
||||
github.com/fyne-io/image v0.1.1 // indirect
|
||||
github.com/fyne-io/oksvg v0.2.0 // indirect
|
||||
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect
|
||||
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a // indirect
|
||||
github.com/go-text/render v0.2.1 // indirect
|
||||
github.com/go-text/typesetting v0.3.4 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||
github.com/hack-pad/safejs v0.1.0 // indirect
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rymdport/portal v0.4.2 // indirect
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
golang.org/x/image v0.24.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
85
go.sum
85
go.sum
|
|
@ -1,4 +1,89 @@
|
|||
fyne.io/fyne/v2 v2.8.0 h1:KNUdIk1eKsXSPy/wU6MdiR1hppAPvyzbjPbtJ8h6EUQ=
|
||||
fyne.io/fyne/v2 v2.8.0/go.mod h1:tLJK7CVtUBOnMiSDR+J88t/quiGuEhwGs09tIVM1RXg=
|
||||
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
|
||||
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg=
|
||||
github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A=
|
||||
github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
|
||||
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||
github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
|
||||
github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8=
|
||||
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
|
||||
github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo=
|
||||
github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU=
|
||||
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
|
||||
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
|
||||
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
|
||||
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
|
||||
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI=
|
||||
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a h1:HWK0MBggT/T6YH7VffE10xBIhqeTq8JzIUPJXrRy87g=
|
||||
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.1.0.20260707082822-2a407d02d01a/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8=
|
||||
github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg=
|
||||
github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A=
|
||||
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
|
||||
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
|
||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
||||
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
|
||||
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
||||
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
|
||||
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc=
|
||||
go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
|
||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
347
internal/client/application.go
Normal file
347
internal/client/application.go
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/application.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Gemeinsame Anwendungsschicht für den rs2322tcp-Client.
|
||||
*
|
||||
* Die Anwendungsschicht verbindet die Konfiguration, die Control-Verbindung
|
||||
* zum Server und die clientseitige Runtime. Sie stellt damit einen einfachen
|
||||
* Lebenszyklus für die spätere grafische Benutzeroberfläche bereit.
|
||||
*
|
||||
* Der technische Client wird bei Start und Reconnect vollständig neu
|
||||
* initialisiert:
|
||||
*
|
||||
* Konfiguration laden
|
||||
* |
|
||||
* v
|
||||
* Lokale virtuelle Ports prüfen
|
||||
* |
|
||||
* v
|
||||
* Server verbinden
|
||||
* |
|
||||
* v
|
||||
* Geräteliste abfragen
|
||||
* |
|
||||
* v
|
||||
* Runtime starten
|
||||
*
|
||||
* Ein Reconnect beendet zunächst die bestehende Runtime und Control-
|
||||
* Verbindung und führt anschließend denselben Startablauf erneut aus.
|
||||
*
|
||||
* Die Anwendungsschicht enthält bewusst keine GUI-Logik und keine
|
||||
* Fyne-Abhängigkeit.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Application
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Application represents one running rs2322tcp client application.
|
||||
//
|
||||
// Application owns the current client connection and Runtime. The
|
||||
// configuration file is the persistent source of the client configuration.
|
||||
//
|
||||
// The Application is intended to be used by the command-line entry point
|
||||
// as well as by the later graphical user interface.
|
||||
type Application struct {
|
||||
mu sync.Mutex
|
||||
|
||||
configFile string
|
||||
|
||||
config *config.ClientConfig
|
||||
client *Client
|
||||
runtime *Runtime
|
||||
devices []transport.RemoteDeviceInfo
|
||||
|
||||
runtimeDone chan error
|
||||
}
|
||||
|
||||
// NewApplication creates a new client Application.
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
|
||||
cfg, err := config.LoadClient(configFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load client configuration: %w", err)
|
||||
}
|
||||
|
||||
return &Application{
|
||||
configFile: configFile,
|
||||
config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Start
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Start loads the client configuration, validates the locally available
|
||||
// virtual ports, connects to the server and starts the client Runtime.
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
|
||||
if a.runtime != nil || a.client != nil {
|
||||
a.mu.Unlock()
|
||||
|
||||
return fmt.Errorf("application is already running")
|
||||
}
|
||||
|
||||
a.mu.Unlock()
|
||||
|
||||
cfg, err := config.LoadClient(a.configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load client configuration: %w", err)
|
||||
}
|
||||
|
||||
// Before connecting to the server, make sure that the virtual ports
|
||||
// configured in client.json match the locally installed virtual ports.
|
||||
//
|
||||
// This check is deliberately performed before New(address), so an
|
||||
// invalid local virtual-port configuration prevents the client from
|
||||
// starting at all.
|
||||
if err := validateLocalVirtualPorts(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
address := fmt.Sprintf(
|
||||
"%s:%d",
|
||||
cfg.Server.Address,
|
||||
cfg.Server.Port,
|
||||
)
|
||||
|
||||
client, err := New(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
devices, err := client.GetDevices()
|
||||
if err != nil {
|
||||
_ = client.Close()
|
||||
|
||||
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 = runtime
|
||||
a.devices = append(
|
||||
[]transport.RemoteDeviceInfo(nil),
|
||||
devices...,
|
||||
)
|
||||
a.runtimeDone = runtimeDone
|
||||
|
||||
a.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
runtimeDone <- runtime.Run(*cfg)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Reconnect
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Reconnect stops the current client connection and starts it again.
|
||||
//
|
||||
// The configuration file is loaded again during Start. This is intentional:
|
||||
// changes made by the GUI are therefore picked up automatically.
|
||||
//
|
||||
// Reconnect is also useful when the server connection has been lost or the
|
||||
// user explicitly requests a new connection.
|
||||
func (a *Application) Reconnect() error {
|
||||
if a == nil {
|
||||
return fmt.Errorf("application is nil")
|
||||
}
|
||||
|
||||
if err := a.Close(); err != nil {
|
||||
return fmt.Errorf("close current connection: %w", err)
|
||||
}
|
||||
|
||||
return a.Start()
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Close
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Close stops the Runtime and closes the client control connection.
|
||||
//
|
||||
// Calling Close more than once is safe.
|
||||
func (a *Application) Close() error {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
|
||||
runtime := a.runtime
|
||||
client := a.client
|
||||
|
||||
a.runtime = nil
|
||||
a.client = nil
|
||||
a.devices = nil
|
||||
a.runtimeDone = nil
|
||||
|
||||
a.mu.Unlock()
|
||||
|
||||
var firstErr error
|
||||
|
||||
if runtime != nil {
|
||||
if err := runtime.Close(); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
} else if client != nil {
|
||||
if err := client.Close(); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Configuration
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Config returns a copy of the currently loaded client configuration.
|
||||
//
|
||||
// The configuration is available after NewApplication, even when the
|
||||
// server connection has not yet been established.
|
||||
func (a *Application) Config() *config.ClientConfig {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.config == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := *a.config
|
||||
|
||||
cfg.VirtualPorts = append(
|
||||
[]config.VirtualPortConfig(nil),
|
||||
a.config.VirtualPorts...,
|
||||
)
|
||||
|
||||
return &cfg
|
||||
}
|
||||
|
||||
// SaveConfig saves the supplied client configuration to the configuration
|
||||
// file used by this Application.
|
||||
//
|
||||
// After a successful save the supplied configuration also becomes the
|
||||
// current in-memory configuration.
|
||||
//
|
||||
// The running Runtime is not restarted automatically. A subsequent
|
||||
// Reconnect or application restart will use the saved configuration.
|
||||
func (a *Application) SaveConfig(
|
||||
cfg *config.ClientConfig,
|
||||
) error {
|
||||
if a == nil {
|
||||
return fmt.Errorf("application is nil")
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("client configuration is nil")
|
||||
}
|
||||
|
||||
if err := config.SaveClient(a.configFile, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.config = cfg
|
||||
a.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Devices
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Devices returns a copy of the remote devices received from the server.
|
||||
//
|
||||
// The returned slice belongs to the caller and can therefore be modified
|
||||
// without changing the Application state.
|
||||
func (a *Application) Devices() []transport.RemoteDeviceInfo {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
return append(
|
||||
[]transport.RemoteDeviceInfo(nil),
|
||||
a.devices...,
|
||||
)
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Runtime state
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Running reports whether the application currently owns a Runtime and a
|
||||
// client connection.
|
||||
func (a *Application) Running() bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
return a.runtime != nil && a.client != nil
|
||||
}
|
||||
344
internal/client/application_test.go
Normal file
344
internal/client/application_test.go
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/application_test.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Tests für die Anwendungsschicht des rs2322tcp-Clients.
|
||||
*
|
||||
* Die Tests prüfen den Lebenszyklus der Application und die lokale
|
||||
* Konfigurationsverwaltung, ohne dafür eine echte Serververbindung
|
||||
* aufbauen zu müssen.
|
||||
*
|
||||
* Insbesondere wird sichergestellt, dass eine neu erzeugte Application
|
||||
* ihre lokale Konfiguration bereits kennt, obwohl noch keine Verbindung
|
||||
* zum Server besteht.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Test helpers
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// writeTestConfig creates a temporary client configuration file.
|
||||
func writeTestConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
filename := filepath.Join(dir, "client.json")
|
||||
|
||||
content := `{
|
||||
"server": {
|
||||
"address": "127.0.0.1",
|
||||
"port": 5000
|
||||
},
|
||||
"virtual_port_range": {
|
||||
"first": 100,
|
||||
"last": 199
|
||||
},
|
||||
"virtual_ports": [
|
||||
{
|
||||
"port": "/dev/ttyUSB100",
|
||||
"remote_device": "radio"
|
||||
},
|
||||
{
|
||||
"port": "/dev/ttyUSB101",
|
||||
"remote_device": "rotor"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(
|
||||
filename,
|
||||
[]byte(content),
|
||||
0600,
|
||||
); err != nil {
|
||||
t.Fatalf("write test configuration: %v", err)
|
||||
}
|
||||
|
||||
return filename
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// NewApplication
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// TestNewApplicationRejectsEmptyConfigFile verifies that an empty
|
||||
// configuration-file path is rejected.
|
||||
func TestNewApplicationRejectsEmptyConfigFile(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
application, err := NewApplication("")
|
||||
if err == nil {
|
||||
t.Fatal("NewApplication with empty config file returned no error")
|
||||
}
|
||||
|
||||
if application != nil {
|
||||
t.Fatal("NewApplication with empty config file returned an application")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewApplication verifies that a valid configuration-file path creates
|
||||
// an application and loads the local configuration without starting the
|
||||
// network connection.
|
||||
func TestNewApplication(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
configFile := writeTestConfig(t)
|
||||
|
||||
application, err := NewApplication(configFile)
|
||||
if err != nil {
|
||||
t.Fatalf("NewApplication: %v", err)
|
||||
}
|
||||
|
||||
if application == nil {
|
||||
t.Fatal("NewApplication returned nil application")
|
||||
}
|
||||
|
||||
if application.Running() {
|
||||
t.Fatal("new application reports running")
|
||||
}
|
||||
|
||||
cfg := application.Config()
|
||||
if cfg == nil {
|
||||
t.Fatal("new application returned no configuration")
|
||||
}
|
||||
|
||||
if len(cfg.VirtualPorts) != 2 {
|
||||
t.Fatalf(
|
||||
"new application returned %d virtual ports, want 2",
|
||||
len(cfg.VirtualPorts),
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.VirtualPorts[0].Port != "/dev/ttyUSB100" {
|
||||
t.Errorf(
|
||||
"virtual port 0 = %q, want %q",
|
||||
cfg.VirtualPorts[0].Port,
|
||||
"/dev/ttyUSB100",
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.VirtualPorts[0].RemoteDevice != "radio" {
|
||||
t.Errorf(
|
||||
"remote device 0 = %q, want %q",
|
||||
cfg.VirtualPorts[0].RemoteDevice,
|
||||
"radio",
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.VirtualPorts[1].Port != "/dev/ttyUSB101" {
|
||||
t.Errorf(
|
||||
"virtual port 1 = %q, want %q",
|
||||
cfg.VirtualPorts[1].Port,
|
||||
"/dev/ttyUSB101",
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.VirtualPorts[1].RemoteDevice != "rotor" {
|
||||
t.Errorf(
|
||||
"remote device 1 = %q, want %q",
|
||||
cfg.VirtualPorts[1].RemoteDevice,
|
||||
"rotor",
|
||||
)
|
||||
}
|
||||
|
||||
if devices := application.Devices(); len(devices) != 0 {
|
||||
t.Fatalf(
|
||||
"new application returned %d devices, want 0",
|
||||
len(devices),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Configuration copy
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// TestApplicationConfigReturnsCopy verifies that modifying the returned
|
||||
// configuration does not modify the Application's internal configuration.
|
||||
func TestApplicationConfigReturnsCopy(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
configFile := writeTestConfig(t)
|
||||
|
||||
application, err := NewApplication(configFile)
|
||||
if err != nil {
|
||||
t.Fatalf("NewApplication: %v", err)
|
||||
}
|
||||
|
||||
cfg := application.Config()
|
||||
if cfg == nil {
|
||||
t.Fatal("Config returned nil")
|
||||
}
|
||||
|
||||
cfg.VirtualPorts[0].Port = "/dev/ttyUSB999"
|
||||
cfg.VirtualPorts[0].RemoteDevice = "changed"
|
||||
|
||||
current := application.Config()
|
||||
if current == nil {
|
||||
t.Fatal("second Config returned nil")
|
||||
}
|
||||
|
||||
if current.VirtualPorts[0].Port != "/dev/ttyUSB100" {
|
||||
t.Errorf(
|
||||
"internal port changed to %q",
|
||||
current.VirtualPorts[0].Port,
|
||||
)
|
||||
}
|
||||
|
||||
if current.VirtualPorts[0].RemoteDevice != "radio" {
|
||||
t.Errorf(
|
||||
"internal remote device changed to %q",
|
||||
current.VirtualPorts[0].RemoteDevice,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Close
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// TestApplicationCloseBeforeStart verifies that Close can safely be called
|
||||
// before the application has been started and that the local configuration
|
||||
// remains available.
|
||||
func TestApplicationCloseBeforeStart(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
configFile := writeTestConfig(t)
|
||||
|
||||
application, err := NewApplication(configFile)
|
||||
if err != nil {
|
||||
t.Fatalf("NewApplication: %v", err)
|
||||
}
|
||||
|
||||
if err := application.Close(); err != nil {
|
||||
t.Fatalf("Close before Start: %v", err)
|
||||
}
|
||||
|
||||
if application.Running() {
|
||||
t.Fatal("application reports running after Close")
|
||||
}
|
||||
|
||||
if cfg := application.Config(); cfg == nil {
|
||||
t.Fatal("configuration was lost after Close")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplicationCloseIsIdempotent verifies that Close can be called more
|
||||
// than once without producing an error and without losing the local
|
||||
// configuration.
|
||||
func TestApplicationCloseIsIdempotent(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
configFile := writeTestConfig(t)
|
||||
|
||||
application, err := NewApplication(configFile)
|
||||
if err != nil {
|
||||
t.Fatalf("NewApplication: %v", err)
|
||||
}
|
||||
|
||||
if err := application.Close(); err != nil {
|
||||
t.Fatalf("first Close: %v", err)
|
||||
}
|
||||
|
||||
if err := application.Close(); err != nil {
|
||||
t.Fatalf("second Close: %v", err)
|
||||
}
|
||||
|
||||
if application.Running() {
|
||||
t.Fatal("application reports running after repeated Close")
|
||||
}
|
||||
|
||||
if cfg := application.Config(); cfg == nil {
|
||||
t.Fatal("configuration was lost after repeated Close")
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Invalid configuration
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// TestNewApplicationRejectsInvalidConfiguration verifies that an invalid
|
||||
// configuration file is rejected immediately.
|
||||
func TestNewApplicationRejectsInvalidConfiguration(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
filename := filepath.Join(dir, "invalid.json")
|
||||
|
||||
if err := os.WriteFile(
|
||||
filename,
|
||||
[]byte(`{"invalid": true}`),
|
||||
0600,
|
||||
); err != nil {
|
||||
t.Fatalf("write invalid configuration: %v", err)
|
||||
}
|
||||
|
||||
application, err := NewApplication(filename)
|
||||
if err == nil {
|
||||
t.Fatal("NewApplication with invalid configuration returned no error")
|
||||
}
|
||||
|
||||
if application != nil {
|
||||
t.Fatal(
|
||||
"NewApplication with invalid configuration returned an application",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Nil receiver
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// TestNilApplication verifies that the public lifecycle methods behave
|
||||
// safely when called on a nil Application receiver.
|
||||
//
|
||||
// This is deliberately a small defensive test. The GUI should normally
|
||||
// never operate on a nil Application.
|
||||
func TestNilApplication(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
var application *Application
|
||||
|
||||
if err := application.Start(); err == nil {
|
||||
t.Fatal("nil Application Start returned no error")
|
||||
}
|
||||
|
||||
if err := application.Reconnect(); err == nil {
|
||||
t.Fatal("nil Application Reconnect returned no error")
|
||||
}
|
||||
|
||||
if err := application.Close(); err != nil {
|
||||
t.Fatalf("nil Application Close: %v", err)
|
||||
}
|
||||
|
||||
if application.Running() {
|
||||
t.Fatal("nil Application reports running")
|
||||
}
|
||||
|
||||
if application.Config() != nil {
|
||||
t.Fatal("nil Application returned a configuration")
|
||||
}
|
||||
|
||||
if devices := application.Devices(); devices != nil {
|
||||
t.Fatal("nil Application returned devices")
|
||||
}
|
||||
}
|
||||
|
||||
// Keep config imported explicitly so the test documents that the temporary
|
||||
// file represents the normal ClientConfig structure.
|
||||
var _ config.ClientConfig
|
||||
|
|
@ -9,13 +9,21 @@
|
|||
* Beschreibung:
|
||||
* Aufbau einer Verbindung zwischen einem lokalen virtuellen seriellen Port
|
||||
* und dem zugehörigen entfernten Gerät.
|
||||
*
|
||||
* Die konfigurierte lokale virtuelle Schnittstelle wird dabei exakt
|
||||
* übernommen.
|
||||
*
|
||||
* Optional können nach dem Aufbau der Datenverbindung konfigurierte
|
||||
* Startbytes an das entfernte Gerät gesendet werden.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||
|
|
@ -25,10 +33,19 @@ import (
|
|||
// Virtual port connection
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// connectVirtualPort opens the local virtual port and the corresponding
|
||||
// remote TCP data connection and returns both endpoints.
|
||||
// connectVirtualPort opens the configured local virtual port and the
|
||||
// corresponding remote TCP data connection and returns both endpoints.
|
||||
//
|
||||
// The caller owns the returned virtual port and TCP connection.
|
||||
//
|
||||
// The local virtual port is opened using exactly the port number specified
|
||||
// in virtualPortConfig.Port. This preserves the user's configured mapping
|
||||
// between local virtual interfaces and remote devices.
|
||||
//
|
||||
// If StartupBytes are configured, they are sent once immediately after the
|
||||
// TCP data connection has been established. Invalid startup bytes do not
|
||||
// abort the connection setup. A warning is printed and the connection
|
||||
// continues normally.
|
||||
func connectVirtualPort(
|
||||
c *Client,
|
||||
manager *VirtualPortManager,
|
||||
|
|
@ -48,10 +65,16 @@ func connectVirtualPort(
|
|||
return nil, nil, err
|
||||
}
|
||||
|
||||
virtualPort, err := manager.Open()
|
||||
portNumber, err := virtualPortNumber(virtualPortConfig.Port)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
virtualPort, err := manager.OpenSpecific(portNumber)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf(
|
||||
"open virtual port for %q: %w",
|
||||
"open virtual port %q for %q: %w",
|
||||
virtualPortConfig.Port,
|
||||
virtualPortConfig.RemoteDevice,
|
||||
err,
|
||||
)
|
||||
|
|
@ -68,9 +91,63 @@ func connectVirtualPort(
|
|||
)
|
||||
}
|
||||
|
||||
if err := sendStartupBytes(conn, virtualPortConfig); err != nil {
|
||||
fmt.Printf(
|
||||
"warning: startup bytes for %q were not sent: %v\n",
|
||||
virtualPortConfig.RemoteDevice,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return virtualPort, conn, nil
|
||||
}
|
||||
|
||||
// sendStartupBytes sends the optional configured startup byte sequence over
|
||||
// the already established TCP data connection.
|
||||
//
|
||||
// An empty StartupBytes configuration does not send anything and is not an
|
||||
// error.
|
||||
//
|
||||
// The configured value may contain spaces between hexadecimal bytes, for
|
||||
// example:
|
||||
//
|
||||
// 57 00 00 00 00 00 00 00 00 00 00 1F 20
|
||||
func sendStartupBytes(
|
||||
conn net.Conn,
|
||||
virtualPortConfig config.VirtualPortConfig,
|
||||
) error {
|
||||
startupBytes := strings.TrimSpace(virtualPortConfig.StartupBytes)
|
||||
if startupBytes == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
hexText := strings.Join(strings.Fields(startupBytes), "")
|
||||
|
||||
data, err := hex.DecodeString(hexText)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid hexadecimal byte sequence: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
written, err := conn.Write(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send startup bytes: %w", err)
|
||||
}
|
||||
|
||||
if written != len(data) {
|
||||
return fmt.Errorf(
|
||||
"send startup bytes: wrote %d of %d bytes",
|
||||
written,
|
||||
len(data),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Bridge connection
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ func TestConnectVirtualPortNilClient(t *testing.T) {
|
|||
)
|
||||
|
||||
cfg := config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
Port: virtualPortPath(100),
|
||||
RemoteDevice: "radio",
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +172,7 @@ func TestConnectVirtualPortNilClient(t *testing.T) {
|
|||
|
||||
func TestConnectVirtualPortNilManager(t *testing.T) {
|
||||
cfg := config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
Port: virtualPortPath(100),
|
||||
RemoteDevice: "radio",
|
||||
}
|
||||
|
||||
|
|
@ -209,7 +209,7 @@ func TestConnectVirtualPortUnknownDevice(t *testing.T) {
|
|||
client := newTestClient(t)
|
||||
|
||||
cfg := config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
Port: virtualPortPath(100),
|
||||
RemoteDevice: "unknown",
|
||||
}
|
||||
|
||||
|
|
@ -249,7 +249,7 @@ func TestConnectVirtualPortClosesPortWhenDataConnectionFails(t *testing.T) {
|
|||
client := newTestClient(t)
|
||||
|
||||
cfg := config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
Port: virtualPortPath(100),
|
||||
RemoteDevice: "radio",
|
||||
}
|
||||
|
||||
|
|
@ -323,7 +323,7 @@ func TestConnectVirtualPortSuccess(t *testing.T) {
|
|||
)
|
||||
|
||||
cfg := config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
Port: virtualPortPath(100),
|
||||
RemoteDevice: "radio",
|
||||
}
|
||||
|
||||
|
|
@ -348,11 +348,11 @@ func TestConnectVirtualPortSuccess(t *testing.T) {
|
|||
defer virtualPort.Close()
|
||||
defer dataConn.Close()
|
||||
|
||||
if virtualPort.Path() != "/dev/ttyUSB100" {
|
||||
if virtualPort.Path() != virtualPortPath(100) {
|
||||
t.Fatalf(
|
||||
"virtual port path = %q, want %q",
|
||||
virtualPort.Path(),
|
||||
"/dev/ttyUSB100",
|
||||
virtualPortPath(100),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -367,6 +367,88 @@ func TestConnectVirtualPortSuccess(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestConnectVirtualPortUsesConfiguredPort(t *testing.T) {
|
||||
dataAddress, accepted := startTestDataServer(t)
|
||||
|
||||
device := remoteDeviceFromAddress(
|
||||
t,
|
||||
"radio",
|
||||
dataAddress,
|
||||
)
|
||||
|
||||
client := newTestClient(t)
|
||||
|
||||
manager := NewVirtualPortManager(
|
||||
config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
},
|
||||
)
|
||||
|
||||
cfg := config.VirtualPortConfig{
|
||||
Port: virtualPortPath(100),
|
||||
RemoteDevice: "radio",
|
||||
}
|
||||
|
||||
virtualPort, dataConn, err := connectVirtualPort(
|
||||
client,
|
||||
manager,
|
||||
cfg,
|
||||
[]transport.RemoteDeviceInfo{device},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("connectVirtualPort: %v", err)
|
||||
}
|
||||
|
||||
if virtualPort == nil {
|
||||
t.Fatal("virtual port is nil")
|
||||
}
|
||||
|
||||
if dataConn == nil {
|
||||
t.Fatal("data connection is nil")
|
||||
}
|
||||
|
||||
defer virtualPort.Close()
|
||||
defer dataConn.Close()
|
||||
|
||||
if virtualPort.Path() != virtualPortPath(100) {
|
||||
t.Fatalf(
|
||||
"virtual port path = %q, want %q",
|
||||
virtualPort.Path(),
|
||||
virtualPortPath(100),
|
||||
)
|
||||
}
|
||||
|
||||
// Port 100 is the configured port and therefore must be reserved.
|
||||
if !manager.used[100] {
|
||||
t.Fatal("configured port 100 is not reserved")
|
||||
}
|
||||
|
||||
// Port 101 must still be available. This proves that the connection
|
||||
// did not simply take another free port.
|
||||
number, err := manager.Reserve()
|
||||
if err != nil {
|
||||
t.Fatalf("Reserve() after connection: %v", err)
|
||||
}
|
||||
|
||||
if number != 101 {
|
||||
t.Fatalf(
|
||||
"first free port after connection = %d, want 101",
|
||||
number,
|
||||
)
|
||||
}
|
||||
|
||||
manager.Release(number)
|
||||
|
||||
select {
|
||||
case conn := <-accepted:
|
||||
_ = conn.Close()
|
||||
|
||||
case <-t.Context().Done():
|
||||
t.Fatal("test context cancelled while waiting for data connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectBridgeSuccess(t *testing.T) {
|
||||
dataAddress, accepted := startTestDataServer(t)
|
||||
|
||||
|
|
@ -386,7 +468,7 @@ func TestConnectBridgeSuccess(t *testing.T) {
|
|||
)
|
||||
|
||||
cfg := config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
Port: virtualPortPath(100),
|
||||
RemoteDevice: "radio",
|
||||
}
|
||||
|
||||
|
|
@ -423,11 +505,11 @@ func TestConnectBridgeSuccess(t *testing.T) {
|
|||
t.Fatal("bridge TCP endpoint does not match data connection")
|
||||
}
|
||||
|
||||
if virtualPort.Path() != "/dev/ttyUSB100" {
|
||||
if virtualPort.Path() != virtualPortPath(100) {
|
||||
t.Fatalf(
|
||||
"virtual port path = %q, want %q",
|
||||
virtualPort.Path(),
|
||||
"/dev/ttyUSB100",
|
||||
virtualPortPath(100),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,16 @@ import (
|
|||
"git.lang-dieter.de/rs2322tcp/internal/server"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Test configuration
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const testHardwareErrorResponse = "TEST ERROR RESPONSE"
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Virtual serial pair
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func startVirtualSerialPair(t *testing.T) (string, string, func()) {
|
||||
t.Helper()
|
||||
|
||||
|
|
@ -79,6 +89,10 @@ func startVirtualSerialPair(t *testing.T) (string, string, func()) {
|
|||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Read helper
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func readExactWithTimeout(
|
||||
t *testing.T,
|
||||
reader io.Reader,
|
||||
|
|
@ -105,6 +119,10 @@ func readExactWithTimeout(
|
|||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Integration test
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func TestClientServerSerialIntegration(t *testing.T) {
|
||||
serialA, serialB, cleanup := startVirtualSerialPair(t)
|
||||
defer cleanup()
|
||||
|
|
@ -130,6 +148,7 @@ func TestClientServerSerialIntegration(t *testing.T) {
|
|||
Address: "127.0.0.1",
|
||||
Port: controlPort,
|
||||
},
|
||||
HardwareErrorResponse: testHardwareErrorResponse,
|
||||
Devices: []config.DeviceConfig{
|
||||
{
|
||||
ID: "radio",
|
||||
|
|
|
|||
|
|
@ -205,7 +205,30 @@ func (p *ptySerial) Write(b []byte) (int, error) {
|
|||
return 0, fmt.Errorf("PTY master is closed")
|
||||
}
|
||||
|
||||
return unix.Write(fd, b)
|
||||
fmt.Printf(
|
||||
"PTY TX [%s]: % X\n",
|
||||
p.path,
|
||||
b,
|
||||
)
|
||||
|
||||
n, err := unix.Write(fd, b)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf(
|
||||
"PTY TX Fehler [%s]: %v\n",
|
||||
p.path,
|
||||
err,
|
||||
)
|
||||
return n, err
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"PTY TX erfolgreich [%s]: %d Bytes\n",
|
||||
p.path,
|
||||
n,
|
||||
)
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Close closes the PTY master and slave.
|
||||
|
|
|
|||
|
|
@ -99,6 +99,10 @@ func (r *Runtime) Run(
|
|||
}
|
||||
|
||||
for _, virtualPortConfig := range cfg.VirtualPorts {
|
||||
if virtualPortConfig.RemoteDevice == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
bridge, _, _, err := connectBridge(
|
||||
r.client,
|
||||
r.manager,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ func TestClientRuntimeSerialIntegration(t *testing.T) {
|
|||
Address: "127.0.0.1",
|
||||
Port: controlPort,
|
||||
},
|
||||
HardwareErrorResponse: testHardwareErrorResponse,
|
||||
Devices: []config.DeviceConfig{
|
||||
{
|
||||
ID: "radio",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
* Link unter ~/.rs2322tcp/virtual/.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
|
|
@ -75,6 +76,9 @@ func ensureVirtualPortDirectory() error {
|
|||
//
|
||||
// The link itself is located below ~/.rs2322tcp/virtual/ and points to the
|
||||
// current PTY, for example /dev/pts/2.
|
||||
//
|
||||
// The public /dev/ttyUSBxxx installation link is deliberately never
|
||||
// modified here.
|
||||
func setVirtualPortLink(portPath string, target string) error {
|
||||
if portPath == "" {
|
||||
return fmt.Errorf("virtual port path is empty")
|
||||
|
|
@ -93,8 +97,10 @@ func setVirtualPortLink(portPath string, target string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Remove an existing link. The installation-owned outer /dev/ttyUSBxxx
|
||||
// link is deliberately never touched here.
|
||||
// Replace only the user-owned internal link.
|
||||
//
|
||||
// The installation-owned /dev/ttyUSBxxx link is deliberately never
|
||||
// touched here.
|
||||
if err := os.Remove(linkPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf(
|
||||
"remove existing virtual port link %q: %w",
|
||||
|
|
@ -115,9 +121,12 @@ func setVirtualPortLink(portPath string, target string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// removeVirtualPortLink removes the internal link for a virtual port.
|
||||
// removeVirtualPortLink removes the user-owned internal link for a virtual
|
||||
// port.
|
||||
//
|
||||
// The public /dev/ttyUSBxxx link is never modified.
|
||||
// This function is retained for explicit link-management operations and
|
||||
// tests. It is NOT called by ManagedVirtualPort.Close(), because the
|
||||
// installation-owned symlink chain must remain available.
|
||||
func removeVirtualPortLink(portPath string) error {
|
||||
if portPath == "" {
|
||||
return fmt.Errorf("virtual port path is empty")
|
||||
|
|
@ -130,7 +139,7 @@ func removeVirtualPortLink(portPath string) error {
|
|||
|
||||
if err := os.Remove(linkPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf(
|
||||
"remove virtual port link %q: %w",
|
||||
"remove existing virtual port link %q: %w",
|
||||
linkPath,
|
||||
err,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ package client
|
|||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
)
|
||||
|
||||
func TestVirtualPortLinkPath(t *testing.T) {
|
||||
|
|
@ -27,7 +30,11 @@ func TestVirtualPortLinkPath(t *testing.T) {
|
|||
t.Fatalf("virtualPortLinkPath() failed: %v", err)
|
||||
}
|
||||
|
||||
wantSuffix := filepath.Join(".rs2322tcp", "virtual", "ttyUSB100")
|
||||
wantSuffix := filepath.Join(
|
||||
".rs2322tcp",
|
||||
"virtual",
|
||||
"ttyUSB100",
|
||||
)
|
||||
|
||||
if !filepath.IsAbs(path) {
|
||||
t.Fatalf("path is not absolute: %q", path)
|
||||
|
|
@ -91,12 +98,24 @@ func TestSetVirtualPortLinkReplacesExistingLink(t *testing.T) {
|
|||
|
||||
portPath := "/dev/ttyUSB100"
|
||||
|
||||
if err := setVirtualPortLink(portPath, "/dev/pts/2"); err != nil {
|
||||
t.Fatalf("first setVirtualPortLink() failed: %v", err)
|
||||
if err := setVirtualPortLink(
|
||||
portPath,
|
||||
"/dev/pts/2",
|
||||
); err != nil {
|
||||
t.Fatalf(
|
||||
"first setVirtualPortLink() failed: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := setVirtualPortLink(portPath, "/dev/pts/7"); err != nil {
|
||||
t.Fatalf("second setVirtualPortLink() failed: %v", err)
|
||||
if err := setVirtualPortLink(
|
||||
portPath,
|
||||
"/dev/pts/7",
|
||||
); err != nil {
|
||||
t.Fatalf(
|
||||
"second setVirtualPortLink() failed: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
linkPath, err := virtualPortLinkPath(portPath)
|
||||
|
|
@ -146,6 +165,182 @@ func TestVirtualPortLinkPathUsesBaseName(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestExistingVirtualPortLinksNone(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
cfg := config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 102,
|
||||
}
|
||||
|
||||
got, err := existingVirtualPortLinksInDirectory(
|
||||
cfg,
|
||||
tempDir,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"existingVirtualPortLinksInDirectory() failed: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if len(got) != 0 {
|
||||
t.Fatalf(
|
||||
"existingVirtualPortLinksInDirectory() = %v, want empty list",
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingVirtualPortLinksFindsSymlinks(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
cfg := config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 102,
|
||||
}
|
||||
|
||||
for _, number := range []int{100, 102} {
|
||||
path := filepath.Join(
|
||||
tempDir,
|
||||
"ttyUSB"+itoa(number),
|
||||
)
|
||||
|
||||
if err := os.Symlink(
|
||||
"/dev/pts/"+itoa(number),
|
||||
path,
|
||||
); err != nil {
|
||||
t.Fatalf(
|
||||
"create test symlink %q: %v",
|
||||
path,
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := existingVirtualPortLinksInDirectory(
|
||||
cfg,
|
||||
tempDir,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"existingVirtualPortLinksInDirectory() failed: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
filepath.Join(tempDir, "ttyUSB100"),
|
||||
filepath.Join(tempDir, "ttyUSB102"),
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf(
|
||||
"existingVirtualPortLinksInDirectory() = %v, want %v",
|
||||
got,
|
||||
want,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingVirtualPortLinksIgnoresRegularFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
cfg := config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 102,
|
||||
}
|
||||
|
||||
path := filepath.Join(tempDir, "ttyUSB100")
|
||||
|
||||
if err := os.WriteFile(
|
||||
path,
|
||||
[]byte("not a symlink"),
|
||||
0644,
|
||||
); err != nil {
|
||||
t.Fatalf(
|
||||
"create regular test file %q: %v",
|
||||
path,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
got, err := existingVirtualPortLinksInDirectory(
|
||||
cfg,
|
||||
tempDir,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"existingVirtualPortLinksInDirectory() failed: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if len(got) != 0 {
|
||||
t.Fatalf(
|
||||
"regular file was detected as virtual port: %v",
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingVirtualPortLinksIgnoresOutsideRange(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
cfg := config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 102,
|
||||
}
|
||||
|
||||
path := filepath.Join(tempDir, "ttyUSB103")
|
||||
|
||||
if err := os.Symlink(
|
||||
"/dev/pts/103",
|
||||
path,
|
||||
); err != nil {
|
||||
t.Fatalf(
|
||||
"create outside-range symlink %q: %v",
|
||||
path,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
got, err := existingVirtualPortLinksInDirectory(
|
||||
cfg,
|
||||
tempDir,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"existingVirtualPortLinksInDirectory() failed: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if len(got) != 0 {
|
||||
t.Fatalf(
|
||||
"outside-range symlink was detected: %v",
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(number int) string {
|
||||
const digits = "0123456789"
|
||||
|
||||
if number == 0 {
|
||||
return "0"
|
||||
}
|
||||
|
||||
result := ""
|
||||
|
||||
for number > 0 {
|
||||
result = string(digits[number%10]) + result
|
||||
number /= 10
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func hasPathSuffix(path string, suffix string) bool {
|
||||
return path == suffix ||
|
||||
len(path) > len(suffix) &&
|
||||
|
|
|
|||
|
|
@ -9,8 +9,23 @@
|
|||
* Beschreibung:
|
||||
* Verwaltung und Auswahl der für rs2322tcp reservierten virtuellen
|
||||
* seriellen Ports.
|
||||
*
|
||||
* Der VirtualPortManager verwaltet den für rs2322tcp vorgesehenen Bereich
|
||||
* virtueller serieller Schnittstellen.
|
||||
*
|
||||
* Die konkrete Darstellung eines virtuellen Ports ist plattformabhängig.
|
||||
* Unter Linux werden beispielsweise /dev/ttyUSBxxx-Schnittstellen verwendet,
|
||||
* während unter Windows COMxxx-Schnittstellen verwendet werden.
|
||||
*
|
||||
* Die eigentliche Erzeugung beziehungsweise Bereitstellung des seriellen
|
||||
* Ports erfolgt ebenfalls plattformabhängig und ist nicht Bestandteil dieses
|
||||
* Managers.
|
||||
*
|
||||
* Ports können entweder automatisch über den nächsten freien Port oder
|
||||
* gezielt über eine vorgegebene Portnummer reserviert werden.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
|
|
@ -24,12 +39,15 @@ import (
|
|||
// VirtualPortManager
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// VirtualPortManager verwaltet den reservierten Bereich virtueller
|
||||
// /dev/ttyUSBxxx-Schnittstellen.
|
||||
// VirtualPortManager verwaltet den für rs2322tcp reservierten Bereich
|
||||
// virtueller serieller Schnittstellen.
|
||||
//
|
||||
// Der reservierte Portbereich und die Belegungstabelle werden durch einen
|
||||
// Mutex geschützt, da mehrere Bridges beziehungsweise Goroutinen gleichzeitig
|
||||
// virtuelle Ports öffnen und freigeben können.
|
||||
//
|
||||
// Die konkrete Darstellung des sichtbaren Portnamens wird über
|
||||
// virtualPortPath() von der jeweiligen Plattform bestimmt.
|
||||
type VirtualPortManager struct {
|
||||
mu sync.Mutex
|
||||
|
||||
|
|
@ -38,6 +56,10 @@ type VirtualPortManager struct {
|
|||
used map[int]bool
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Constructor
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// NewVirtualPortManager erzeugt einen VirtualPortManager aus dem
|
||||
// konfigurierten Portbereich.
|
||||
func NewVirtualPortManager(
|
||||
|
|
@ -50,20 +72,39 @@ func NewVirtualPortManager(
|
|||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Port path
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// PortPath erzeugt den für die externe Software sichtbaren Gerätenamen
|
||||
// für eine Portnummer.
|
||||
//
|
||||
// Die konkrete Darstellung ist plattformabhängig:
|
||||
//
|
||||
// Linux /dev/ttyUSB100
|
||||
// Windows COM100
|
||||
//
|
||||
// Die Plattformimplementierung befindet sich in
|
||||
// virtual_port_path_linux.go beziehungsweise virtual_port_path_windows.go.
|
||||
func (m *VirtualPortManager) PortPath(number int) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("/dev/ttyUSB%d", number)
|
||||
return virtualPortPath(number)
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Reservation
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Reserve reserviert den nächsten freien virtuellen Port.
|
||||
//
|
||||
// Es wird immer mit dem kleinsten freien Port im konfigurierten Bereich
|
||||
// begonnen.
|
||||
//
|
||||
// Diese Methode bleibt für Fälle erhalten, in denen keine bestimmte
|
||||
// Portnummer vorgegeben ist.
|
||||
func (m *VirtualPortManager) Reserve() (int, error) {
|
||||
if m == nil {
|
||||
return 0, fmt.Errorf("virtual port manager is nil")
|
||||
|
|
@ -89,6 +130,46 @@ func (m *VirtualPortManager) Reserve() (int, error) {
|
|||
)
|
||||
}
|
||||
|
||||
// ReserveSpecific reserviert exakt den angegebenen virtuellen Port.
|
||||
//
|
||||
// Der Port muss innerhalb des konfigurierten Bereichs liegen und darf
|
||||
// momentan nicht bereits reserviert sein.
|
||||
//
|
||||
// Diese Methode wird für konfigurierte Zuordnungen verwendet, bei denen
|
||||
// die Portnummer aus client.json verbindlich ist.
|
||||
func (m *VirtualPortManager) ReserveSpecific(number int) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("virtual port manager is nil")
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if number < m.first || number > m.last {
|
||||
return fmt.Errorf(
|
||||
"virtual port %d is outside configured range %d-%d",
|
||||
number,
|
||||
m.first,
|
||||
m.last,
|
||||
)
|
||||
}
|
||||
|
||||
if m.used[number] {
|
||||
return fmt.Errorf(
|
||||
"virtual port %d is already reserved",
|
||||
number,
|
||||
)
|
||||
}
|
||||
|
||||
m.used[number] = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Release
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Release gibt einen zuvor reservierten Port wieder frei.
|
||||
func (m *VirtualPortManager) Release(number int) {
|
||||
if m == nil {
|
||||
|
|
|
|||
|
|
@ -9,147 +9,39 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Linux-spezifische Erzeugung und Verwaltung virtueller rs2322tcp-Ports.
|
||||
* Linux-spezifische Erzeugung eines virtuellen rs2322tcp-Ports.
|
||||
*
|
||||
* Der öffentliche /dev/ttyUSBxxx-Eintrag wird bei der Installation
|
||||
* bereitgestellt. Der Client verwaltet ausschließlich den internen Link
|
||||
* unter ~/.rs2322tcp/virtual/.
|
||||
* Die gemeinsame Verwaltung von ManagedVirtualPort und VirtualPortManager
|
||||
* befindet sich in virtual_port_manager_open.go.
|
||||
*
|
||||
* Unter Linux wird der PTY über newVirtualSerial() erzeugt und der interne
|
||||
* Link unter ~/.rs2322tcp/virtual/ auf diesen PTY gesetzt.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Managed virtual port
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// ManagedVirtualPort verbindet einen reservierten Port mit einer
|
||||
// VirtualSerial-Instanz und verwaltet deren Lebenszyklus.
|
||||
// openReserved creates the actual Linux virtual serial port for an already
|
||||
// reserved port number.
|
||||
//
|
||||
// Der Mutex schützt den internen Port-Pointer gegen gleichzeitige Zugriffe
|
||||
// aus Read, Write und Close.
|
||||
type ManagedVirtualPort struct {
|
||||
mu sync.Mutex
|
||||
|
||||
port *VirtualPort
|
||||
manager *VirtualPortManager
|
||||
number int
|
||||
}
|
||||
|
||||
// Path returns the device path visible to the external application.
|
||||
func (p *ManagedVirtualPort) Path() string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.port == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return p.port.Path()
|
||||
}
|
||||
|
||||
// Read reads data from the underlying virtual serial device.
|
||||
func (p *ManagedVirtualPort) Read(b []byte) (int, error) {
|
||||
if p == nil {
|
||||
return 0, fmt.Errorf("managed virtual port is nil")
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
port := p.port
|
||||
p.mu.Unlock()
|
||||
|
||||
if port == nil {
|
||||
return 0, fmt.Errorf("managed virtual port is closed")
|
||||
}
|
||||
|
||||
return port.Read(b)
|
||||
}
|
||||
|
||||
// Write writes data to the underlying virtual serial device.
|
||||
func (p *ManagedVirtualPort) Write(b []byte) (int, error) {
|
||||
if p == nil {
|
||||
return 0, fmt.Errorf("managed virtual port is nil")
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
port := p.port
|
||||
p.mu.Unlock()
|
||||
|
||||
if port == nil {
|
||||
return 0, fmt.Errorf("managed virtual port is closed")
|
||||
}
|
||||
|
||||
return port.Write(b)
|
||||
}
|
||||
|
||||
// Close removes the internal link, closes the PTY and releases the
|
||||
// reservation.
|
||||
// The caller must reserve the port before calling this method.
|
||||
//
|
||||
// The managed port is detached from the object before the underlying PTY
|
||||
// is closed. This prevents a concurrent Read or Write from accessing the
|
||||
// managed port pointer after Close has taken ownership of it.
|
||||
func (p *ManagedVirtualPort) Close() error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
|
||||
port := p.port
|
||||
p.port = nil
|
||||
|
||||
manager := p.manager
|
||||
number := p.number
|
||||
|
||||
p.mu.Unlock()
|
||||
|
||||
if port == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
|
||||
if err := removeVirtualPortLink(port.Path()); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
|
||||
if err := port.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
|
||||
if manager != nil {
|
||||
manager.Release(number)
|
||||
}
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Open
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Open creates a new virtual serial port using the next free port number.
|
||||
//
|
||||
// The internal PTY is created by newVirtualSerial(). The internal
|
||||
// ~/.rs2322tcp/virtual/ttyUSBxxx link is then pointed at the PTY.
|
||||
func (m *VirtualPortManager) Open() (*ManagedVirtualPort, error) {
|
||||
// The public /dev/ttyUSBxxx link is not modified here. The installation-owned
|
||||
// link remains untouched. Only the user-owned internal link below
|
||||
// ~/.rs2322tcp/virtual/ is created or updated to point to the newly created
|
||||
// PTY.
|
||||
func openReserved(
|
||||
m *VirtualPortManager,
|
||||
number int,
|
||||
) (*ManagedVirtualPort, error) {
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("virtual port manager is nil")
|
||||
}
|
||||
|
||||
number, err := m.Reserve()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
portPath := m.PortPath(number)
|
||||
|
||||
serial, err := newVirtualSerial()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Linux-spezifische Tests für die Erzeugung virtueller rs2322tcp-Ports.
|
||||
* Linux-spezifische Tests für die Erzeugung und Verwaltung virtueller
|
||||
* rs2322tcp-Ports.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
|
@ -71,10 +72,84 @@ func TestVirtualPortManagerOpen(t *testing.T) {
|
|||
t.Fatalf("Close() failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Lstat(linkPath); !os.IsNotExist(err) {
|
||||
targetAfterClose, err := os.Readlink(linkPath)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"internal link still exists after Close(): %q",
|
||||
"internal link does not exist after Close(): %q: %v",
|
||||
linkPath,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if targetAfterClose != target {
|
||||
t.Errorf(
|
||||
"internal link target after Close() = %q, want %q",
|
||||
targetAfterClose,
|
||||
target,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerOpenSpecific(t *testing.T) {
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("HOME", tempHome)
|
||||
|
||||
manager := NewVirtualPortManager(
|
||||
config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
},
|
||||
)
|
||||
|
||||
port, err := manager.OpenSpecific(150)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSpecific(150) failed: %v", err)
|
||||
}
|
||||
|
||||
if port == nil {
|
||||
t.Fatal("OpenSpecific(150) returned nil")
|
||||
}
|
||||
|
||||
if port.Path() != "/dev/ttyUSB150" {
|
||||
t.Errorf(
|
||||
"Path() = %q, want %q",
|
||||
port.Path(),
|
||||
"/dev/ttyUSB150",
|
||||
)
|
||||
}
|
||||
|
||||
linkPath, err := virtualPortLinkPath(port.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("virtualPortLinkPath() failed: %v", err)
|
||||
}
|
||||
|
||||
target, err := os.Readlink(linkPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Readlink() failed: %v", err)
|
||||
}
|
||||
|
||||
if target == "" {
|
||||
t.Fatal("internal virtual port link has empty target")
|
||||
}
|
||||
|
||||
if err := port.Close(); err != nil {
|
||||
t.Fatalf("Close() failed: %v", err)
|
||||
}
|
||||
|
||||
targetAfterClose, err := os.Readlink(linkPath)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"internal link does not exist after Close(): %q: %v",
|
||||
linkPath,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if targetAfterClose != target {
|
||||
t.Errorf(
|
||||
"internal link target after Close() = %q, want %q",
|
||||
targetAfterClose,
|
||||
target,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
163
internal/client/virtual_port_manager_open.go
Normal file
163
internal/client/virtual_port_manager_open.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_manager_open.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Plattformneutrale Verwaltung geöffneter virtueller Ports.
|
||||
*
|
||||
* Die konkrete Erzeugung eines virtuellen seriellen Ports erfolgt über
|
||||
* openVirtualPort() in der jeweiligen Plattformimplementierung.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Managed virtual port
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// ManagedVirtualPort verbindet einen reservierten Port mit einer
|
||||
// VirtualSerial-Instanz und verwaltet deren Lebenszyklus.
|
||||
type ManagedVirtualPort struct {
|
||||
mu sync.Mutex
|
||||
|
||||
port *VirtualPort
|
||||
manager *VirtualPortManager
|
||||
number int
|
||||
}
|
||||
|
||||
// Path returns the device path visible to the external application.
|
||||
func (p *ManagedVirtualPort) Path() string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.port == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return p.port.Path()
|
||||
}
|
||||
|
||||
// Read reads data from the underlying virtual serial device.
|
||||
func (p *ManagedVirtualPort) Read(b []byte) (int, error) {
|
||||
if p == nil {
|
||||
return 0, fmt.Errorf("managed virtual port is nil")
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
port := p.port
|
||||
p.mu.Unlock()
|
||||
|
||||
if port == nil {
|
||||
return 0, fmt.Errorf("managed virtual port is closed")
|
||||
}
|
||||
|
||||
return port.Read(b)
|
||||
}
|
||||
|
||||
// Write writes data to the underlying virtual serial device.
|
||||
func (p *ManagedVirtualPort) Write(b []byte) (int, error) {
|
||||
if p == nil {
|
||||
return 0, fmt.Errorf("managed virtual port is nil")
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
port := p.port
|
||||
p.mu.Unlock()
|
||||
|
||||
if port == nil {
|
||||
return 0, fmt.Errorf("managed virtual port is closed")
|
||||
}
|
||||
|
||||
return port.Write(b)
|
||||
}
|
||||
|
||||
// Close closes the managed virtual port and releases its reservation.
|
||||
func (p *ManagedVirtualPort) Close() error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
|
||||
port := p.port
|
||||
p.port = nil
|
||||
|
||||
manager := p.manager
|
||||
number := p.number
|
||||
|
||||
p.mu.Unlock()
|
||||
|
||||
if port == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := port.Close()
|
||||
|
||||
if manager != nil {
|
||||
manager.Release(number)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Open
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Open creates a new virtual serial port using the next free port number.
|
||||
func (m *VirtualPortManager) Open() (*ManagedVirtualPort, error) {
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("virtual port manager is nil")
|
||||
}
|
||||
|
||||
number, err := m.Reserve()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
port, err := openVirtualPort(m, number)
|
||||
if err != nil {
|
||||
m.Release(number)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// OpenSpecific creates a new virtual serial port using exactly the specified
|
||||
// port number.
|
||||
func (m *VirtualPortManager) OpenSpecific(
|
||||
number int,
|
||||
) (*ManagedVirtualPort, error) {
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("virtual port manager is nil")
|
||||
}
|
||||
|
||||
if err := m.ReserveSpecific(number); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
port, err := openVirtualPort(m, number)
|
||||
if err != nil {
|
||||
m.Release(number)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return port, nil
|
||||
}
|
||||
|
|
@ -8,6 +8,10 @@
|
|||
*
|
||||
* Beschreibung:
|
||||
* Tests für die Auswahl und Verwaltung virtueller serieller Ports.
|
||||
*
|
||||
* Die Tests decken sowohl die automatische Reservierung über Reserve()
|
||||
* als auch die gezielte Reservierung eines bestimmten Ports über
|
||||
* ReserveSpecific() ab.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
|
@ -103,8 +107,10 @@ func TestVirtualPortManagerRelease(t *testing.T) {
|
|||
}
|
||||
|
||||
if reused != 100 {
|
||||
t.Errorf("Reserve() after Release() = %d, want 100",
|
||||
reused)
|
||||
t.Errorf(
|
||||
"Reserve() after Release() = %d, want 100",
|
||||
reused,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -175,15 +181,15 @@ func TestVirtualPortManagerPortPath(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
number: 100,
|
||||
want: "/dev/ttyUSB100",
|
||||
want: virtualPortPath(100),
|
||||
},
|
||||
{
|
||||
number: 123,
|
||||
want: "/dev/ttyUSB123",
|
||||
want: virtualPortPath(123),
|
||||
},
|
||||
{
|
||||
number: 199,
|
||||
want: "/dev/ttyUSB199",
|
||||
want: virtualPortPath(199),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -201,6 +207,84 @@ func TestVirtualPortManagerPortPath(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerReserveSpecific(t *testing.T) {
|
||||
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
})
|
||||
|
||||
if err := manager.ReserveSpecific(150); err != nil {
|
||||
t.Fatalf("ReserveSpecific(150) failed: %v", err)
|
||||
}
|
||||
|
||||
if !manager.used[150] {
|
||||
t.Fatal("ReserveSpecific(150) did not reserve port 150")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerReserveSpecificOutsideRange(t *testing.T) {
|
||||
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
})
|
||||
|
||||
tests := []int{
|
||||
99,
|
||||
200,
|
||||
}
|
||||
|
||||
for _, number := range tests {
|
||||
if err := manager.ReserveSpecific(number); err == nil {
|
||||
t.Errorf(
|
||||
"ReserveSpecific(%d) succeeded, want range error",
|
||||
number,
|
||||
)
|
||||
}
|
||||
|
||||
if manager.used[number] {
|
||||
t.Errorf(
|
||||
"ReserveSpecific(%d) marked out-of-range port as used",
|
||||
number,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerReserveSpecificAlreadyReserved(t *testing.T) {
|
||||
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
})
|
||||
|
||||
if err := manager.ReserveSpecific(100); err != nil {
|
||||
t.Fatalf("first ReserveSpecific(100) failed: %v", err)
|
||||
}
|
||||
|
||||
if err := manager.ReserveSpecific(100); err == nil {
|
||||
t.Fatal("second ReserveSpecific(100) succeeded, want already-reserved error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerReserveSpecificAfterRelease(t *testing.T) {
|
||||
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
})
|
||||
|
||||
if err := manager.ReserveSpecific(150); err != nil {
|
||||
t.Fatalf("first ReserveSpecific(150) failed: %v", err)
|
||||
}
|
||||
|
||||
manager.Release(150)
|
||||
|
||||
if err := manager.ReserveSpecific(150); err != nil {
|
||||
t.Fatalf(
|
||||
"ReserveSpecific(150) after Release() failed: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerNil(t *testing.T) {
|
||||
var manager *VirtualPortManager
|
||||
|
||||
|
|
@ -212,5 +296,9 @@ func TestVirtualPortManagerNil(t *testing.T) {
|
|||
t.Fatal("nil Reserve() succeeded, want error")
|
||||
}
|
||||
|
||||
if err := manager.ReserveSpecific(100); err == nil {
|
||||
t.Fatal("nil ReserveSpecific() succeeded, want error")
|
||||
}
|
||||
|
||||
manager.Release(100)
|
||||
}
|
||||
|
|
|
|||
59
internal/client/virtual_port_number_linux.go
Normal file
59
internal/client/virtual_port_number_linux.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
//go:build linux
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_number_linux.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Linux-spezifische Umwandlung eines konfigurierten virtuellen
|
||||
* seriellen Ports in dessen numerische Portnummer.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Virtual port number
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// virtualPortNumber converts a configured /dev/ttyUSBxxx path into its
|
||||
// numeric virtual port number.
|
||||
func virtualPortNumber(path string) (int, error) {
|
||||
const prefix = "/dev/ttyUSB"
|
||||
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return 0, fmt.Errorf(
|
||||
"invalid virtual port %q: expected /dev/ttyUSB<number>",
|
||||
path,
|
||||
)
|
||||
}
|
||||
|
||||
value := strings.TrimPrefix(path, prefix)
|
||||
|
||||
if value == "" {
|
||||
return 0, fmt.Errorf(
|
||||
"invalid virtual port %q: port number is missing",
|
||||
path,
|
||||
)
|
||||
}
|
||||
|
||||
number, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"invalid virtual port %q: invalid port number",
|
||||
path,
|
||||
)
|
||||
}
|
||||
|
||||
return number, nil
|
||||
}
|
||||
62
internal/client/virtual_port_number_windows.go
Normal file
62
internal/client/virtual_port_number_windows.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
//go:build windows
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_number_windows.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Windows-spezifische Umwandlung eines konfigurierten virtuellen
|
||||
* seriellen COM-Ports in dessen numerische Portnummer.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Virtual port number
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// virtualPortNumber converts a configured COMxxx path into its numeric
|
||||
// virtual port number.
|
||||
func virtualPortNumber(path string) (int, error) {
|
||||
const prefix = "COM"
|
||||
|
||||
if !strings.HasPrefix(
|
||||
strings.ToUpper(path),
|
||||
prefix,
|
||||
) {
|
||||
return 0, fmt.Errorf(
|
||||
"invalid virtual port %q: expected COM<number>",
|
||||
path,
|
||||
)
|
||||
}
|
||||
|
||||
value := path[len(prefix):]
|
||||
|
||||
if value == "" {
|
||||
return 0, fmt.Errorf(
|
||||
"invalid virtual port %q: port number is missing",
|
||||
path,
|
||||
)
|
||||
}
|
||||
|
||||
number, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"invalid virtual port %q: invalid port number",
|
||||
path,
|
||||
)
|
||||
}
|
||||
|
||||
return number, nil
|
||||
}
|
||||
77
internal/client/virtual_port_open_linux.go
Normal file
77
internal/client/virtual_port_open_linux.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
//go:build linux
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_open_linux.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Linux-spezifische Erzeugung eines verwalteten virtuellen rs2322tcp-Ports.
|
||||
*
|
||||
* Die virtuelle serielle Schnittstelle wird über ein PTY-Paar erzeugt.
|
||||
* Der öffentliche /dev/ttyUSBxxx-Eintrag wird über den bereits vorhandenen
|
||||
* internen Link auf den PTY-Slave abgebildet.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package client
|
||||
|
||||
import "fmt"
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Open virtual port
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// openVirtualPort creates and initializes a Linux virtual serial port.
|
||||
//
|
||||
// The port number must already have been reserved by the
|
||||
// VirtualPortManager.
|
||||
//
|
||||
// On failure, the caller remains responsible for releasing the reservation.
|
||||
func openVirtualPort(
|
||||
manager *VirtualPortManager,
|
||||
number int,
|
||||
) (*ManagedVirtualPort, error) {
|
||||
if manager == nil {
|
||||
return nil, fmt.Errorf("virtual port manager is nil")
|
||||
}
|
||||
|
||||
portPath := manager.PortPath(number)
|
||||
|
||||
serial, err := newVirtualSerial()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"create virtual serial for %s: %w",
|
||||
portPath,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := setVirtualPortLink(
|
||||
portPath,
|
||||
serial.Path(),
|
||||
); err != nil {
|
||||
_ = serial.Close()
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"bind %s to %s: %w",
|
||||
portPath,
|
||||
serial.Path(),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
port := NewVirtualPort(
|
||||
portPath,
|
||||
serial,
|
||||
)
|
||||
|
||||
return &ManagedVirtualPort{
|
||||
port: port,
|
||||
manager: manager,
|
||||
number: number,
|
||||
}, nil
|
||||
}
|
||||
87
internal/client/virtual_port_open_windows.go
Normal file
87
internal/client/virtual_port_open_windows.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
//go:build windows
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_open_windows.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Windows-spezifische Erzeugung eines verwalteten virtuellen rs2322tcp-Ports.
|
||||
*
|
||||
* Der konfigurierte virtuelle COM-Port wird einmal geöffnet und anschließend
|
||||
* über dasselbe Handle für Lese- und Schreiboperationen verwendet.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
bugserial "go.bug.st/serial"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Open virtual port
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// openVirtualPort creates and initializes a Windows virtual serial port.
|
||||
//
|
||||
// The port number must already have been reserved by the
|
||||
// VirtualPortManager.
|
||||
//
|
||||
// The visible COM port is provided by the platform-specific PortPath()
|
||||
// implementation. The actual COM-port pair configuration is managed
|
||||
// externally by the Windows virtual COM-port driver.
|
||||
//
|
||||
// The COM port is opened exactly once. The resulting handle is used for
|
||||
// parallel read and write operations.
|
||||
func openVirtualPort(
|
||||
manager *VirtualPortManager,
|
||||
number int,
|
||||
) (*ManagedVirtualPort, error) {
|
||||
if manager == nil {
|
||||
return nil, fmt.Errorf("virtual port manager is nil")
|
||||
}
|
||||
|
||||
portPath := manager.PortPath(number)
|
||||
if portPath == "" {
|
||||
return nil, fmt.Errorf(
|
||||
"virtual port path is empty for port %d",
|
||||
number,
|
||||
)
|
||||
}
|
||||
|
||||
mode := &bugserial.Mode{
|
||||
BaudRate: 9600,
|
||||
DataBits: 8,
|
||||
Parity: bugserial.NoParity,
|
||||
StopBits: bugserial.OneStopBit,
|
||||
}
|
||||
|
||||
port, err := bugserial.Open(portPath, mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"open virtual serial port %s: %w",
|
||||
portPath,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
virtualPort := NewVirtualPort(
|
||||
portPath,
|
||||
&windowsSerial{
|
||||
port: port,
|
||||
path: portPath,
|
||||
},
|
||||
)
|
||||
|
||||
return &ManagedVirtualPort{
|
||||
port: virtualPort,
|
||||
manager: manager,
|
||||
number: number,
|
||||
}, nil
|
||||
}
|
||||
43
internal/client/virtual_port_path_linux.go
Normal file
43
internal/client/virtual_port_path_linux.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//go:build linux
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_path_linux.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Linux-spezifische Darstellung eines für rs2322tcp reservierten
|
||||
* virtuellen seriellen Ports.
|
||||
*
|
||||
* Unter Linux werden die für rs2322tcp sichtbaren virtuellen Ports als
|
||||
*
|
||||
* /dev/ttyUSBxxx
|
||||
*
|
||||
* dargestellt.
|
||||
*
|
||||
* Die eigentliche Bereitstellung des virtuellen seriellen Ports erfolgt
|
||||
* unabhängig davon in der Linux-spezifischen VirtualSerial-Implementierung.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package client
|
||||
|
||||
import "fmt"
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Virtual port path
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// virtualPortPath erzeugt den unter Linux sichtbaren Gerätenamen für eine
|
||||
// virtuelle rs2322tcp-Schnittstelle.
|
||||
//
|
||||
// Beispiel:
|
||||
//
|
||||
// number = 100
|
||||
// result = /dev/ttyUSB100
|
||||
func virtualPortPath(number int) string {
|
||||
return fmt.Sprintf("/dev/ttyUSB%d", number)
|
||||
}
|
||||
48
internal/client/virtual_port_path_windows.go
Normal file
48
internal/client/virtual_port_path_windows.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
//go:build windows
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_path_windows.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Windows-spezifische Darstellung eines für rs2322tcp reservierten
|
||||
* virtuellen seriellen Ports.
|
||||
*
|
||||
* Unter Windows werden die für rs2322tcp sichtbaren virtuellen Ports als
|
||||
*
|
||||
* COMxxx
|
||||
*
|
||||
* dargestellt.
|
||||
*
|
||||
* Die eigentliche Bereitstellung des virtuellen COM-Ports erfolgt durch die
|
||||
* Windows-spezifische serielle Infrastruktur beziehungsweise später durch
|
||||
* den für rs2322tcp vorgesehenen virtuellen COM-Port-Treiber.
|
||||
*
|
||||
* Diese Datei enthält ausschließlich die plattformabhängige Darstellung des
|
||||
* Portnamens. Die gemeinsame Portverwaltung bleibt in
|
||||
* virtual_port_manager.go erhalten.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package client
|
||||
|
||||
import "fmt"
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Virtual port path
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// virtualPortPath erzeugt den unter Windows sichtbaren Gerätenamen für eine
|
||||
// virtuelle rs2322tcp-Schnittstelle.
|
||||
//
|
||||
// Beispiel:
|
||||
//
|
||||
// number = 10
|
||||
// result = COM10
|
||||
func virtualPortPath(number int) string {
|
||||
return fmt.Sprintf("COM%d", number)
|
||||
}
|
||||
252
internal/client/virtual_port_validation.go
Normal file
252
internal/client/virtual_port_validation.go
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_validation.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Plattformneutrale Prüfung der in client.json definierten virtuellen Ports
|
||||
* gegen die tatsächlich vorhandenen virtuellen Schnittstellen.
|
||||
*
|
||||
* Die Ermittlung der vorhandenen Ports erfolgt über die
|
||||
* plattformabhängige Funktion existingVirtualPortLinks().
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Virtual port validation
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// VirtualPortValidationResult contains the differences between configured
|
||||
// and locally available virtual ports.
|
||||
type VirtualPortValidationResult struct {
|
||||
Missing []string
|
||||
Extra []string
|
||||
}
|
||||
|
||||
// compareVirtualPorts compares the configured virtual ports with the
|
||||
// locally available virtual ports.
|
||||
//
|
||||
// Missing contains ports configured in client.json that are not available
|
||||
// locally.
|
||||
//
|
||||
// Extra contains locally available ports that are not configured in
|
||||
// client.json.
|
||||
//
|
||||
// The order of both input lists is irrelevant.
|
||||
func compareVirtualPorts(
|
||||
configured []string,
|
||||
available []string,
|
||||
) VirtualPortValidationResult {
|
||||
configuredSet := make(map[string]struct{}, len(configured))
|
||||
|
||||
for _, port := range configured {
|
||||
configuredSet[port] = struct{}{}
|
||||
}
|
||||
|
||||
availableSet := make(map[string]struct{}, len(available))
|
||||
|
||||
for _, port := range available {
|
||||
availableSet[port] = struct{}{}
|
||||
}
|
||||
|
||||
result := VirtualPortValidationResult{
|
||||
Missing: make([]string, 0),
|
||||
Extra: make([]string, 0),
|
||||
}
|
||||
|
||||
for port := range configuredSet {
|
||||
if _, ok := availableSet[port]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
result.Missing = append(result.Missing, port)
|
||||
}
|
||||
|
||||
for port := range availableSet {
|
||||
if _, ok := configuredSet[port]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
result.Extra = append(result.Extra, port)
|
||||
}
|
||||
|
||||
sort.Strings(result.Missing)
|
||||
sort.Strings(result.Extra)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Local virtual-port validation
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// validateLocalVirtualPorts compares the virtual ports defined in
|
||||
// client.json with the virtual ports currently available on the local
|
||||
// computer.
|
||||
//
|
||||
// The detection of available ports is performed by the platform-specific
|
||||
// existingVirtualPortLinks() implementation.
|
||||
//
|
||||
// A mismatch prevents the client from starting.
|
||||
func validateLocalVirtualPorts(
|
||||
cfg *config.ClientConfig,
|
||||
) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("client configuration is nil")
|
||||
}
|
||||
|
||||
configured := make(
|
||||
[]string,
|
||||
0,
|
||||
len(cfg.VirtualPorts),
|
||||
)
|
||||
|
||||
for _, virtualPort := range cfg.VirtualPorts {
|
||||
configured = append(
|
||||
configured,
|
||||
virtualPort.Port,
|
||||
)
|
||||
}
|
||||
|
||||
available, err := existingVirtualPortLinks(
|
||||
cfg.VirtualPortRange,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"prüfen der virtuellen Ports fehlgeschlagen: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
result := compareVirtualPorts(
|
||||
configured,
|
||||
available,
|
||||
)
|
||||
|
||||
if len(result.Missing) == 0 &&
|
||||
len(result.Extra) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var message strings.Builder
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Missing ports
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
if len(result.Missing) > 0 &&
|
||||
len(result.Extra) == 0 {
|
||||
|
||||
message.WriteString(
|
||||
"Virtuelle Ports nicht eingerichtet",
|
||||
)
|
||||
|
||||
message.WriteString(
|
||||
"\n\nFolgende Ports sind in client.json " +
|
||||
"eingetragen, aber auf diesem Rechner " +
|
||||
"nicht eingerichtet:\n\n",
|
||||
)
|
||||
|
||||
for _, port := range result.Missing {
|
||||
message.WriteString(" ")
|
||||
message.WriteString(port)
|
||||
message.WriteByte('\n')
|
||||
}
|
||||
|
||||
message.WriteString(
|
||||
"\nBitte richten Sie diese virtuellen Ports " +
|
||||
"entsprechend der Installationsanleitung ein.",
|
||||
)
|
||||
|
||||
return fmt.Errorf(
|
||||
"%s",
|
||||
strings.TrimSpace(message.String()),
|
||||
)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Extra ports
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
if len(result.Missing) == 0 &&
|
||||
len(result.Extra) > 0 {
|
||||
|
||||
message.WriteString(
|
||||
"Virtuelle Ports nicht konfiguriert",
|
||||
)
|
||||
|
||||
message.WriteString(
|
||||
"\n\nFolgende virtuelle Ports sind auf diesem " +
|
||||
"Rechner eingerichtet, aber nicht in " +
|
||||
"client.json eingetragen:\n\n",
|
||||
)
|
||||
|
||||
for _, port := range result.Extra {
|
||||
message.WriteString(" ")
|
||||
message.WriteString(port)
|
||||
message.WriteByte('\n')
|
||||
}
|
||||
|
||||
message.WriteString(
|
||||
"\nBitte ergänzen Sie die entsprechenden " +
|
||||
"Einträge in client.json.",
|
||||
)
|
||||
|
||||
return fmt.Errorf(
|
||||
"%s",
|
||||
strings.TrimSpace(message.String()),
|
||||
)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Missing and extra ports
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
message.WriteString(
|
||||
"Virtuelle Portkonfiguration stimmt nicht überein",
|
||||
)
|
||||
|
||||
message.WriteString(
|
||||
"\n\nIn client.json eingetragen, aber nicht " +
|
||||
"eingerichtet:\n\n",
|
||||
)
|
||||
|
||||
for _, port := range result.Missing {
|
||||
message.WriteString(" ")
|
||||
message.WriteString(port)
|
||||
message.WriteByte('\n')
|
||||
}
|
||||
|
||||
message.WriteString(
|
||||
"\nEingerichtet, aber nicht in client.json " +
|
||||
"eingetragen:\n\n",
|
||||
)
|
||||
|
||||
for _, port := range result.Extra {
|
||||
message.WriteString(" ")
|
||||
message.WriteString(port)
|
||||
message.WriteByte('\n')
|
||||
}
|
||||
|
||||
message.WriteString(
|
||||
"\nBitte korrigieren Sie die virtuelle " +
|
||||
"Portkonfiguration.",
|
||||
)
|
||||
|
||||
return fmt.Errorf(
|
||||
"%s",
|
||||
strings.TrimSpace(message.String()),
|
||||
)
|
||||
}
|
||||
106
internal/client/virtual_port_validation_linux.go
Normal file
106
internal/client/virtual_port_validation_linux.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//go:build linux
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_validation_linux.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Linux-spezifische Ermittlung der lokal vorhandenen virtuellen
|
||||
* rs2322tcp-Schnittstellen.
|
||||
*
|
||||
* Die öffentliche /dev/ttyUSBxxx-Schnittstelle wird ausschließlich gelesen.
|
||||
* Der Client verändert diese Schnittstellen hier nicht.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Existing virtual ports
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// existingVirtualPortLinks returns all public virtual-port paths that
|
||||
// currently exist as symbolic links.
|
||||
//
|
||||
// Only ports inside the configured virtual-port range are considered.
|
||||
// The public /dev/ttyUSBxxx links are only inspected; they are never
|
||||
// created, changed, or removed by this function.
|
||||
//
|
||||
// This check only requires read access and therefore does not require
|
||||
// root privileges.
|
||||
func existingVirtualPortLinks(
|
||||
cfg config.VirtualPortRangeConfig,
|
||||
) ([]string, error) {
|
||||
return existingVirtualPortLinksInDirectory(
|
||||
cfg,
|
||||
"/dev",
|
||||
)
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Existing virtual ports in directory
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// existingVirtualPortLinksInDirectory returns all virtual-port paths in
|
||||
// directory that currently exist as symbolic links.
|
||||
//
|
||||
// This helper is deliberately separated from existingVirtualPortLinks so
|
||||
// the filesystem inspection can be tested without modifying /dev.
|
||||
func existingVirtualPortLinksInDirectory(
|
||||
cfg config.VirtualPortRangeConfig,
|
||||
directory string,
|
||||
) ([]string, error) {
|
||||
if directory == "" {
|
||||
return nil, fmt.Errorf("virtual port directory is empty")
|
||||
}
|
||||
|
||||
manager := NewVirtualPortManager(cfg)
|
||||
if manager == nil {
|
||||
return nil, fmt.Errorf("virtual port manager is nil")
|
||||
}
|
||||
|
||||
links := make([]string, 0)
|
||||
|
||||
for number := cfg.First; number <= cfg.Last; number++ {
|
||||
portName := filepath.Base(
|
||||
manager.PortPath(number),
|
||||
)
|
||||
|
||||
portPath := filepath.Join(directory, portName)
|
||||
|
||||
info, err := os.Lstat(portPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"inspect virtual port %q: %w",
|
||||
portPath,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
links = append(
|
||||
links,
|
||||
filepath.Join(directory, portName),
|
||||
)
|
||||
}
|
||||
|
||||
return links, nil
|
||||
}
|
||||
283
internal/client/virtual_port_validation_test.go
Normal file
283
internal/client/virtual_port_validation_test.go
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_validation_test.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Tests für den Vergleich der konfigurierten und tatsächlich vorhandenen
|
||||
* virtuellen rs2322tcp-Schnittstellen.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompareVirtualPortsIdentical(t *testing.T) {
|
||||
configured := []string{
|
||||
"/dev/ttyUSB100",
|
||||
"/dev/ttyUSB101",
|
||||
}
|
||||
|
||||
available := []string{
|
||||
"/dev/ttyUSB101",
|
||||
"/dev/ttyUSB100",
|
||||
}
|
||||
|
||||
got := compareVirtualPorts(
|
||||
configured,
|
||||
available,
|
||||
)
|
||||
|
||||
if len(got.Missing) != 0 {
|
||||
t.Errorf(
|
||||
"Missing = %v, want empty",
|
||||
got.Missing,
|
||||
)
|
||||
}
|
||||
|
||||
if len(got.Extra) != 0 {
|
||||
t.Errorf(
|
||||
"Extra = %v, want empty",
|
||||
got.Extra,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVirtualPortsMissing(t *testing.T) {
|
||||
configured := []string{
|
||||
"/dev/ttyUSB100",
|
||||
"/dev/ttyUSB101",
|
||||
"/dev/ttyUSB102",
|
||||
}
|
||||
|
||||
available := []string{
|
||||
"/dev/ttyUSB100",
|
||||
"/dev/ttyUSB101",
|
||||
}
|
||||
|
||||
got := compareVirtualPorts(
|
||||
configured,
|
||||
available,
|
||||
)
|
||||
|
||||
wantMissing := []string{
|
||||
"/dev/ttyUSB102",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got.Missing, wantMissing) {
|
||||
t.Errorf(
|
||||
"Missing = %v, want %v",
|
||||
got.Missing,
|
||||
wantMissing,
|
||||
)
|
||||
}
|
||||
|
||||
if len(got.Extra) != 0 {
|
||||
t.Errorf(
|
||||
"Extra = %v, want empty",
|
||||
got.Extra,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVirtualPortsExtra(t *testing.T) {
|
||||
configured := []string{
|
||||
"/dev/ttyUSB100",
|
||||
"/dev/ttyUSB101",
|
||||
}
|
||||
|
||||
available := []string{
|
||||
"/dev/ttyUSB100",
|
||||
"/dev/ttyUSB101",
|
||||
"/dev/ttyUSB103",
|
||||
}
|
||||
|
||||
got := compareVirtualPorts(
|
||||
configured,
|
||||
available,
|
||||
)
|
||||
|
||||
if len(got.Missing) != 0 {
|
||||
t.Errorf(
|
||||
"Missing = %v, want empty",
|
||||
got.Missing,
|
||||
)
|
||||
}
|
||||
|
||||
wantExtra := []string{
|
||||
"/dev/ttyUSB103",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got.Extra, wantExtra) {
|
||||
t.Errorf(
|
||||
"Extra = %v, want %v",
|
||||
got.Extra,
|
||||
wantExtra,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVirtualPortsMissingAndExtra(t *testing.T) {
|
||||
configured := []string{
|
||||
"/dev/ttyUSB100",
|
||||
"/dev/ttyUSB101",
|
||||
"/dev/ttyUSB102",
|
||||
}
|
||||
|
||||
available := []string{
|
||||
"/dev/ttyUSB100",
|
||||
"/dev/ttyUSB101",
|
||||
"/dev/ttyUSB103",
|
||||
}
|
||||
|
||||
got := compareVirtualPorts(
|
||||
configured,
|
||||
available,
|
||||
)
|
||||
|
||||
wantMissing := []string{
|
||||
"/dev/ttyUSB102",
|
||||
}
|
||||
|
||||
wantExtra := []string{
|
||||
"/dev/ttyUSB103",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got.Missing, wantMissing) {
|
||||
t.Errorf(
|
||||
"Missing = %v, want %v",
|
||||
got.Missing,
|
||||
wantMissing,
|
||||
)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got.Extra, wantExtra) {
|
||||
t.Errorf(
|
||||
"Extra = %v, want %v",
|
||||
got.Extra,
|
||||
wantExtra,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVirtualPortsEmpty(t *testing.T) {
|
||||
got := compareVirtualPorts(
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
if len(got.Missing) != 0 {
|
||||
t.Errorf(
|
||||
"Missing = %v, want empty",
|
||||
got.Missing,
|
||||
)
|
||||
}
|
||||
|
||||
if len(got.Extra) != 0 {
|
||||
t.Errorf(
|
||||
"Extra = %v, want empty",
|
||||
got.Extra,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVirtualPortsConfiguredOnly(t *testing.T) {
|
||||
configured := []string{
|
||||
"/dev/ttyUSB100",
|
||||
"/dev/ttyUSB101",
|
||||
}
|
||||
|
||||
got := compareVirtualPorts(
|
||||
configured,
|
||||
nil,
|
||||
)
|
||||
|
||||
wantMissing := []string{
|
||||
"/dev/ttyUSB100",
|
||||
"/dev/ttyUSB101",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got.Missing, wantMissing) {
|
||||
t.Errorf(
|
||||
"Missing = %v, want %v",
|
||||
got.Missing,
|
||||
wantMissing,
|
||||
)
|
||||
}
|
||||
|
||||
if len(got.Extra) != 0 {
|
||||
t.Errorf(
|
||||
"Extra = %v, want empty",
|
||||
got.Extra,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVirtualPortsAvailableOnly(t *testing.T) {
|
||||
available := []string{
|
||||
"/dev/ttyUSB100",
|
||||
"/dev/ttyUSB101",
|
||||
}
|
||||
|
||||
got := compareVirtualPorts(
|
||||
nil,
|
||||
available,
|
||||
)
|
||||
|
||||
if len(got.Missing) != 0 {
|
||||
t.Errorf(
|
||||
"Missing = %v, want empty",
|
||||
got.Missing,
|
||||
)
|
||||
}
|
||||
|
||||
wantExtra := []string{
|
||||
"/dev/ttyUSB100",
|
||||
"/dev/ttyUSB101",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got.Extra, wantExtra) {
|
||||
t.Errorf(
|
||||
"Extra = %v, want %v",
|
||||
got.Extra,
|
||||
wantExtra,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVirtualPortsIgnoresDuplicates(t *testing.T) {
|
||||
configured := []string{
|
||||
"/dev/ttyUSB100",
|
||||
"/dev/ttyUSB100",
|
||||
}
|
||||
|
||||
available := []string{
|
||||
"/dev/ttyUSB100",
|
||||
"/dev/ttyUSB100",
|
||||
}
|
||||
|
||||
got := compareVirtualPorts(
|
||||
configured,
|
||||
available,
|
||||
)
|
||||
|
||||
if len(got.Missing) != 0 {
|
||||
t.Errorf(
|
||||
"Missing = %v, want empty",
|
||||
got.Missing,
|
||||
)
|
||||
}
|
||||
|
||||
if len(got.Extra) != 0 {
|
||||
t.Errorf(
|
||||
"Extra = %v, want empty",
|
||||
got.Extra,
|
||||
)
|
||||
}
|
||||
}
|
||||
132
internal/client/virtual_port_validation_windows.go
Normal file
132
internal/client/virtual_port_validation_windows.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
//go:build windows
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_validation_windows.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Windows-spezifische Ermittlung der lokal vorhandenen seriellen
|
||||
* Schnittstellen für rs2322tcp.
|
||||
*
|
||||
* Unter Windows werden virtuelle Ports als COMxxx-Schnittstellen
|
||||
* bereitgestellt.
|
||||
*
|
||||
* Diese Implementierung prüft den konfigurierten Portbereich und liefert
|
||||
* die tatsächlich vorhandenen COM-Schnittstellen zurück.
|
||||
*
|
||||
* Die Funktion verändert keine Geräte und installiert keine Treiber.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Existing virtual ports
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// existingVirtualPortLinks returns all COM ports in the configured range
|
||||
// that are currently registered as serial ports in Windows.
|
||||
//
|
||||
// The Windows serial-port information is obtained from the Plug-and-Play
|
||||
// device registry. No device is created, modified, or removed.
|
||||
func existingVirtualPortLinks(
|
||||
cfg config.VirtualPortRangeConfig,
|
||||
) ([]string, error) {
|
||||
key, err := registry.OpenKey(
|
||||
registry.LOCAL_MACHINE,
|
||||
`HARDWARE\DEVICEMAP\SERIALCOMM`,
|
||||
registry.READ,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"open Windows serial-port registry: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
values, err := key.ReadValueNames(0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"read Windows serial-port registry: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
available := make([]string, 0)
|
||||
|
||||
for _, valueName := range values {
|
||||
portName, _, err := key.GetStringValue(valueName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
number, ok := windowsCOMPortNumber(portName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if number < cfg.First || number > cfg.Last {
|
||||
continue
|
||||
}
|
||||
|
||||
available = append(
|
||||
available,
|
||||
portName,
|
||||
)
|
||||
}
|
||||
|
||||
return available, nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// COM port number
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// windowsCOMPortNumber extracts the numeric part of a Windows COM port name.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// COM1 -> 1
|
||||
// COM10 -> 10
|
||||
//
|
||||
// The comparison is case-insensitive.
|
||||
func windowsCOMPortNumber(portName string) (int, bool) {
|
||||
if len(portName) < 4 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if (portName[0] != 'C' && portName[0] != 'c') ||
|
||||
(portName[1] != 'O' && portName[1] != 'o') ||
|
||||
(portName[2] != 'M' && portName[2] != 'm') {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
number := 0
|
||||
|
||||
for _, char := range portName[3:] {
|
||||
if char < '0' || char > '9' {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
number = number*10 + int(char-'0')
|
||||
}
|
||||
|
||||
if number == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return number, true
|
||||
}
|
||||
210
internal/client/virtual_serial_windows.go
Normal file
210
internal/client/virtual_serial_windows.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
//go:build windows
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_serial_windows.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Windows-spezifische Implementierung der virtuellen seriellen Schnittstelle
|
||||
* über go.bug.st/serial.
|
||||
*
|
||||
* Ein COM-Port wird unter Windows nur einmal geöffnet. Dasselbe Handle wird
|
||||
* parallel für Lese- und Schreiboperationen verwendet. Damit kann eine
|
||||
* Goroutine blockierend lesen, während eine andere Goroutine Daten schreibt.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
bugserial "go.bug.st/serial"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// windowsSerial
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// windowsSerial implements VirtualSerial using one Windows COM-port handle
|
||||
// for both reading and writing.
|
||||
type windowsSerial struct {
|
||||
mu sync.Mutex
|
||||
|
||||
port bugserial.Port
|
||||
path string
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Path
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Path returns the Windows COM-port name.
|
||||
func (p *windowsSerial) Path() string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return p.path
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Read
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Read reads raw bytes from the Windows COM-port handle.
|
||||
func (p *windowsSerial) Read(b []byte) (int, error) {
|
||||
if p == nil {
|
||||
return 0, fmt.Errorf("virtual serial port is nil")
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
port := p.port
|
||||
path := p.path
|
||||
p.mu.Unlock()
|
||||
|
||||
if port == nil {
|
||||
return 0, fmt.Errorf("virtual serial port is closed")
|
||||
}
|
||||
|
||||
n, err := port.Read(b)
|
||||
|
||||
if n > 0 {
|
||||
fmt.Printf(
|
||||
"WINDOWS SERIAL RX [%s]: %d Bytes\n% X\n",
|
||||
path,
|
||||
n,
|
||||
b[:n],
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf(
|
||||
"WINDOWS SERIAL RX FEHLER [%s]: nach %d Bytes: %v\n",
|
||||
path,
|
||||
n,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Write
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Write writes raw bytes to the Windows COM-port handle.
|
||||
func (p *windowsSerial) Write(b []byte) (int, error) {
|
||||
if p == nil {
|
||||
return 0, fmt.Errorf("virtual serial port is nil")
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
port := p.port
|
||||
path := p.path
|
||||
p.mu.Unlock()
|
||||
|
||||
if port == nil {
|
||||
return 0, fmt.Errorf("virtual serial port is closed")
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"WINDOWS SERIAL TX [%s]: %d Bytes\n% X\n",
|
||||
path,
|
||||
len(b),
|
||||
b,
|
||||
)
|
||||
|
||||
fmt.Printf(
|
||||
"WINDOWS SERIAL TX [%s]: Rufe port.Write() auf ...\n",
|
||||
path,
|
||||
)
|
||||
|
||||
n, err := port.Write(b)
|
||||
|
||||
fmt.Printf(
|
||||
"WINDOWS SERIAL TX [%s]: port.Write() zurückgekehrt: %d von %d Bytes\n",
|
||||
path,
|
||||
n,
|
||||
len(b),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf(
|
||||
"WINDOWS SERIAL TX FEHLER [%s]: %v\n",
|
||||
path,
|
||||
err,
|
||||
)
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
if n != len(b) {
|
||||
fmt.Printf(
|
||||
"WINDOWS SERIAL TX UNVOLLSTÄNDIG [%s]: %d von %d Bytes\n",
|
||||
path,
|
||||
n,
|
||||
len(b),
|
||||
)
|
||||
} else {
|
||||
fmt.Printf(
|
||||
"WINDOWS SERIAL TX ERFOLGREICH [%s]: %d Bytes\n",
|
||||
path,
|
||||
n,
|
||||
)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Close
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Close closes the Windows COM-port handle.
|
||||
func (p *windowsSerial) Close() error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
|
||||
port := p.port
|
||||
path := p.path
|
||||
|
||||
p.port = nil
|
||||
|
||||
p.mu.Unlock()
|
||||
|
||||
if port == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"WINDOWS SERIAL [%s]: Schließe COM-Port ...\n",
|
||||
path,
|
||||
)
|
||||
|
||||
if err := port.Close(); err != nil {
|
||||
fmt.Printf(
|
||||
"WINDOWS SERIAL [%s]: Fehler beim Schließen des COM-Ports: %v\n",
|
||||
path,
|
||||
err,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"WINDOWS SERIAL [%s]: COM-Port geschlossen.\n",
|
||||
path,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -23,8 +23,10 @@ import (
|
|||
|
||||
// ServerConfig contains the complete server configuration.
|
||||
type ServerConfig struct {
|
||||
Listen ListenConfig `json:"listen"`
|
||||
Devices []DeviceConfig `json:"devices"`
|
||||
Listen ListenConfig `json:"listen"`
|
||||
HardwareErrorResponse string `json:"hardware_error_response"`
|
||||
SerialMonitor bool `json:"serial_monitor"`
|
||||
Devices []DeviceConfig `json:"devices"`
|
||||
}
|
||||
|
||||
// ListenConfig contains the network listener configuration.
|
||||
|
|
@ -71,9 +73,13 @@ type VirtualPortRangeConfig struct {
|
|||
|
||||
// VirtualPortConfig describes one local virtual serial port and the
|
||||
// remote device assigned to it.
|
||||
//
|
||||
// RemoteDevice may be empty. An empty RemoteDevice means that the virtual
|
||||
// port exists but is currently not assigned to a remote device.
|
||||
type VirtualPortConfig struct {
|
||||
Port string `json:"port"`
|
||||
RemoteDevice string `json:"remote_device"`
|
||||
StartupBytes string `json:"startup_bytes,omitempty"`
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -88,6 +94,9 @@ const (
|
|||
// DefaultVirtualPortLast is the last virtual USB serial port number
|
||||
// used when no virtual port range is specified.
|
||||
DefaultVirtualPortLast = 199
|
||||
|
||||
// Fehlermeldung wenn Hardware nicht erreichbar ist
|
||||
DefaultHardwareErrorResponse = "ERROR - HARDWARE NOT AVAILABLE"
|
||||
)
|
||||
|
||||
// DefaultVirtualPortRange returns the default virtual port range.
|
||||
|
|
@ -110,6 +119,10 @@ func LoadServer(filename string) (*ServerConfig, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.HardwareErrorResponse == "" {
|
||||
cfg.HardwareErrorResponse = DefaultHardwareErrorResponse
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -220,6 +233,9 @@ func (cfg *ServerConfig) Validate() error {
|
|||
}
|
||||
|
||||
// Validate checks the client configuration for basic errors.
|
||||
//
|
||||
// A virtual port may exist without a remote-device assignment. This is a
|
||||
// valid state and means that the virtual port is currently free.
|
||||
func (cfg *ClientConfig) Validate() error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("client configuration is nil")
|
||||
|
|
@ -230,30 +246,36 @@ func (cfg *ClientConfig) Validate() error {
|
|||
}
|
||||
|
||||
if cfg.VirtualPortRange.First < DefaultVirtualPortFirst {
|
||||
return fmt.Errorf("virtual port range first must be >= %d: %d",
|
||||
DefaultVirtualPortFirst, cfg.VirtualPortRange.First)
|
||||
return fmt.Errorf(
|
||||
"virtual port range first must be >= %d: %d",
|
||||
DefaultVirtualPortFirst,
|
||||
cfg.VirtualPortRange.First,
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.VirtualPortRange.Last < cfg.VirtualPortRange.First {
|
||||
return fmt.Errorf("invalid virtual port range: %d-%d",
|
||||
cfg.VirtualPortRange.First, cfg.VirtualPortRange.Last)
|
||||
return fmt.Errorf(
|
||||
"invalid virtual port range: %d-%d",
|
||||
cfg.VirtualPortRange.First,
|
||||
cfg.VirtualPortRange.Last,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
return fmt.Errorf(
|
||||
"virtual port %d: port is empty",
|
||||
i,
|
||||
)
|
||||
}
|
||||
|
||||
if ports[virtualPort.Port] {
|
||||
return fmt.Errorf("duplicate virtual port: %q",
|
||||
virtualPort.Port)
|
||||
return fmt.Errorf(
|
||||
"duplicate virtual port: %q",
|
||||
virtualPort.Port,
|
||||
)
|
||||
}
|
||||
|
||||
ports[virtualPort.Port] = true
|
||||
|
|
@ -269,11 +291,19 @@ func (cfg *ClientConfig) Validate() error {
|
|||
func loadJSON(filename string, target interface{}) error {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read configuration %q: %w", filename, err)
|
||||
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 fmt.Errorf(
|
||||
"parse configuration %q: %w",
|
||||
filename,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -288,7 +318,11 @@ func saveJSON(filename string, value interface{}) error {
|
|||
data = append(data, '\n')
|
||||
|
||||
if err := os.WriteFile(filename, data, 0644); err != nil {
|
||||
return fmt.Errorf("write configuration %q: %w", filename, err)
|
||||
return fmt.Errorf(
|
||||
"write configuration %q: %w",
|
||||
filename,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ package config_test
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -50,14 +52,24 @@ func TestLoadServer(t *testing.T) {
|
|||
t.Fatalf("LoadServer() failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.HardwareErrorResponse != config.DefaultHardwareErrorResponse {
|
||||
t.Errorf(
|
||||
"HardwareErrorResponse = %q, want %q",
|
||||
cfg.HardwareErrorResponse,
|
||||
config.DefaultHardwareErrorResponse,
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.Listen.Address != "0.0.0.0" {
|
||||
t.Errorf("Listen.Address = %q, want %q",
|
||||
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)
|
||||
t.Errorf("Listen.Port = %d, want %d", cfg.Listen.Port, 5000)
|
||||
}
|
||||
|
||||
if len(cfg.Devices) != 1 {
|
||||
|
|
@ -67,18 +79,19 @@ func TestLoadServer(t *testing.T) {
|
|||
device := cfg.Devices[0]
|
||||
|
||||
if device.ID != "radio" {
|
||||
t.Errorf("Device.ID = %q, want %q",
|
||||
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")
|
||||
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)
|
||||
t.Errorf("Device.BaudRate = %d, want %d", device.BaudRate, 9600)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,40 +126,106 @@ func TestLoadClient(t *testing.T) {
|
|||
}
|
||||
|
||||
if cfg.Server.Address != "100.64.0.10" {
|
||||
t.Errorf("Server.Address = %q, want %q",
|
||||
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)
|
||||
t.Errorf("Server.Port = %d, want %d", cfg.Server.Port, 5000)
|
||||
}
|
||||
|
||||
if cfg.VirtualPortRange.First != config.DefaultVirtualPortFirst {
|
||||
t.Errorf("VirtualPortRange.First = %d, want %d",
|
||||
cfg.VirtualPortRange.First, config.DefaultVirtualPortFirst)
|
||||
t.Errorf(
|
||||
"VirtualPortRange.First = %d, want %d",
|
||||
cfg.VirtualPortRange.First,
|
||||
config.DefaultVirtualPortFirst,
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.VirtualPortRange.Last != config.DefaultVirtualPortLast {
|
||||
t.Errorf("VirtualPortRange.Last = %d, want %d",
|
||||
cfg.VirtualPortRange.Last, config.DefaultVirtualPortLast)
|
||||
t.Errorf(
|
||||
"VirtualPortRange.Last = %d, want %d",
|
||||
cfg.VirtualPortRange.Last,
|
||||
config.DefaultVirtualPortLast,
|
||||
)
|
||||
}
|
||||
|
||||
if len(cfg.VirtualPorts) != 1 {
|
||||
t.Fatalf("len(VirtualPorts) = %d, want 1",
|
||||
len(cfg.VirtualPorts))
|
||||
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")
|
||||
t.Errorf(
|
||||
"VirtualPort.Port = %q, want %q",
|
||||
virtualPort.Port,
|
||||
"COM7",
|
||||
)
|
||||
}
|
||||
|
||||
if virtualPort.RemoteDevice != "radio" {
|
||||
t.Errorf("VirtualPort.RemoteDevice = %q, want %q",
|
||||
virtualPort.RemoteDevice, "radio")
|
||||
t.Errorf(
|
||||
"VirtualPort.RemoteDevice = %q, want %q",
|
||||
virtualPort.RemoteDevice,
|
||||
"radio",
|
||||
)
|
||||
}
|
||||
|
||||
if virtualPort.StartupBytes != "" {
|
||||
t.Errorf(
|
||||
"VirtualPort.StartupBytes = %q, want empty",
|
||||
virtualPort.StartupBytes,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadClientWithStartupBytes(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",
|
||||
"startup_bytes": "57 00 00 1F 20"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
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 len(cfg.VirtualPorts) != 1 {
|
||||
t.Fatalf(
|
||||
"len(VirtualPorts) = %d, want 1",
|
||||
len(cfg.VirtualPorts),
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.VirtualPorts[0].StartupBytes != "57 00 00 1F 20" {
|
||||
t.Errorf(
|
||||
"VirtualPort.StartupBytes = %q, want %q",
|
||||
cfg.VirtualPorts[0].StartupBytes,
|
||||
"57 00 00 1F 20",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,13 +255,17 @@ func TestLoadClientCustomVirtualPortRange(t *testing.T) {
|
|||
}
|
||||
|
||||
if cfg.VirtualPortRange.First != 200 {
|
||||
t.Errorf("VirtualPortRange.First = %d, want 200",
|
||||
cfg.VirtualPortRange.First)
|
||||
t.Errorf(
|
||||
"VirtualPortRange.First = %d, want 200",
|
||||
cfg.VirtualPortRange.First,
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.VirtualPortRange.Last != 299 {
|
||||
t.Errorf("VirtualPortRange.Last = %d, want 299",
|
||||
cfg.VirtualPortRange.Last)
|
||||
t.Errorf(
|
||||
"VirtualPortRange.Last = %d, want 299",
|
||||
cfg.VirtualPortRange.Last,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -207,6 +290,7 @@ func TestSaveAndLoadClient(t *testing.T) {
|
|||
{
|
||||
Port: "COM7",
|
||||
RemoteDevice: "radio",
|
||||
StartupBytes: "57 00 00 1F 20",
|
||||
},
|
||||
{
|
||||
Port: "COM8",
|
||||
|
|
@ -233,9 +317,11 @@ func TestSaveAndLoadClient(t *testing.T) {
|
|||
}
|
||||
|
||||
if len(loaded.VirtualPorts) != len(original.VirtualPorts) {
|
||||
t.Fatalf("len(VirtualPorts) = %d, want %d",
|
||||
t.Fatalf(
|
||||
"len(VirtualPorts) = %d, want %d",
|
||||
len(loaded.VirtualPorts),
|
||||
len(original.VirtualPorts))
|
||||
len(original.VirtualPorts),
|
||||
)
|
||||
}
|
||||
|
||||
for i := range original.VirtualPorts {
|
||||
|
|
@ -383,8 +469,8 @@ func TestExampleServerConfig(t *testing.T) {
|
|||
t.Fatalf("LoadServer() failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.Devices) != 2 {
|
||||
t.Fatalf("len(Devices) = %d, want 2", len(cfg.Devices))
|
||||
if len(cfg.Devices) != 3 {
|
||||
t.Fatalf("len(Devices) = %d, want 3", len(cfg.Devices))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -394,8 +480,35 @@ func TestExampleClientConfig(t *testing.T) {
|
|||
t.Fatalf("LoadClient() failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.VirtualPorts) != 2 {
|
||||
t.Fatalf("len(VirtualPorts) = %d, want 2", len(cfg.VirtualPorts))
|
||||
if len(cfg.VirtualPorts) != 1 {
|
||||
t.Fatalf(
|
||||
"len(VirtualPorts) = %d, want 1",
|
||||
len(cfg.VirtualPorts),
|
||||
)
|
||||
}
|
||||
|
||||
expectedPort := "COM100"
|
||||
|
||||
if runtime.GOOS == "linux" {
|
||||
expectedPort = "/dev/ttyUSB100"
|
||||
}
|
||||
|
||||
port := cfg.VirtualPorts[0]
|
||||
|
||||
if port.Port != expectedPort {
|
||||
t.Errorf(
|
||||
"VirtualPorts[0].Port = %q, want %q",
|
||||
port.Port,
|
||||
expectedPort,
|
||||
)
|
||||
}
|
||||
|
||||
if port.RemoteDevice != "rotor" {
|
||||
t.Errorf(
|
||||
"VirtualPorts[0].RemoteDevice = %q, want %q",
|
||||
port.RemoteDevice,
|
||||
"rotor",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -417,33 +530,51 @@ func TestDeviceRemoteDevice(t *testing.T) {
|
|||
remote := device.RemoteDevice()
|
||||
|
||||
if remote.ID != "radio" {
|
||||
t.Errorf("RemoteDevice.ID = %q, want %q",
|
||||
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")
|
||||
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)
|
||||
t.Errorf(
|
||||
"RemoteDevice.BaudRate = %d, want %d",
|
||||
remote.BaudRate,
|
||||
9600,
|
||||
)
|
||||
}
|
||||
|
||||
if remote.DataBits != 8 {
|
||||
t.Errorf("RemoteDevice.DataBits = %d, want %d",
|
||||
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")
|
||||
t.Errorf(
|
||||
"RemoteDevice.Parity = %q, want %q",
|
||||
remote.Parity,
|
||||
"none",
|
||||
)
|
||||
}
|
||||
|
||||
if remote.StopBits != 1 {
|
||||
t.Errorf("RemoteDevice.StopBits = %d, want %d",
|
||||
remote.StopBits, 1)
|
||||
t.Errorf(
|
||||
"RemoteDevice.StopBits = %d, want %d",
|
||||
remote.StopBits,
|
||||
1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -482,28 +613,41 @@ func TestServerRemoteDevices(t *testing.T) {
|
|||
}
|
||||
|
||||
if list.Devices[0].ID != "radio" {
|
||||
t.Errorf("Devices[0].ID = %q, want %q",
|
||||
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")
|
||||
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")
|
||||
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 9600",
|
||||
list.Devices[0].BaudRate)
|
||||
t.Errorf(
|
||||
"Devices[0].BaudRate = %d, want 9600",
|
||||
list.Devices[0].BaudRate,
|
||||
)
|
||||
}
|
||||
|
||||
if list.Devices[1].BaudRate != 4800 {
|
||||
t.Errorf("Devices[1].BaudRate = %d, want 4800",
|
||||
list.Devices[1].BaudRate)
|
||||
t.Errorf(
|
||||
"Devices[1].BaudRate = %d, want 4800",
|
||||
list.Devices[1].BaudRate,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -553,11 +697,17 @@ func TestRemoteDeviceJSON(t *testing.T) {
|
|||
}
|
||||
|
||||
if len(decoded.Devices) != 1 {
|
||||
t.Fatalf("len(decoded.Devices) = %d, want 1", len(decoded.Devices))
|
||||
t.Fatalf(
|
||||
"len(decoded.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")
|
||||
t.Errorf(
|
||||
"Devices[0].ID = %q, want %q",
|
||||
decoded.Devices[0].ID,
|
||||
"radio",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
729
internal/gui/app.go
Normal file
729
internal/gui/app.go
Normal file
|
|
@ -0,0 +1,729 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/gui/app.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Grafische Benutzeroberfläche des rs2322tcp-Clients.
|
||||
*
|
||||
* Die eigentliche Kommunikation mit dem Server sowie die Verwaltung der
|
||||
* virtuellen seriellen Ports bleiben vollständig in internal/client.
|
||||
*
|
||||
* Die Gerätezuordnung wird in einem separaten Fenster bearbeitet.
|
||||
*
|
||||
* Änderungen an der Gerätezuordnung werden zunächst ausschließlich im
|
||||
* Arbeitsspeicher des Zuordnungsfensters gehalten.
|
||||
*
|
||||
* Beim Schließen des Zuordnungsfensters werden vorhandene Änderungen
|
||||
* gespeichert.
|
||||
*
|
||||
* Wird dagegen das Hauptfenster geschlossen, während das Zuordnungsfenster
|
||||
* noch ungespeicherte Änderungen enthält, wird der Anwender gefragt, ob
|
||||
* diese Änderungen verworfen werden sollen.
|
||||
* ============================================================================
|
||||
*/
|
||||
package gui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/client"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// App
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// App represents the graphical rs2322tcp client application.
|
||||
//
|
||||
// App owns the Fyne window and the technical client application. The GUI
|
||||
// does not access the Runtime or the Client directly. All technical client
|
||||
// operations are performed through client.Application.
|
||||
type App struct {
|
||||
fyneApp fyne.App
|
||||
window fyne.Window
|
||||
|
||||
clientApplication *client.Application
|
||||
|
||||
statusLabel *widget.Label
|
||||
errorLabel *widget.Label
|
||||
reconnectButton *widget.Button
|
||||
assignmentButton *widget.Button
|
||||
|
||||
assignmentWindow fyne.Window
|
||||
assignmentEditor *AssignmentEditor
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Construction
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// NewApp creates the graphical rs2322tcp client application.
|
||||
//
|
||||
// No network connection is established by NewApp. Start must be called
|
||||
// separately.
|
||||
func NewApp(
|
||||
fyneApp fyne.App,
|
||||
configFile string,
|
||||
) (*App, error) {
|
||||
if fyneApp == nil {
|
||||
return nil, fmt.Errorf("fyne application is nil")
|
||||
}
|
||||
|
||||
application, err := client.NewApplication(configFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
window := fyneApp.NewWindow("rs2322tcp Client")
|
||||
window.Resize(fyne.NewSize(800, 600))
|
||||
|
||||
app := &App{
|
||||
fyneApp: fyneApp,
|
||||
window: window,
|
||||
clientApplication: application,
|
||||
statusLabel: widget.NewLabel("Server: nicht verbunden"),
|
||||
errorLabel: widget.NewLabel(""),
|
||||
}
|
||||
|
||||
app.reconnectButton = widget.NewButton(
|
||||
"Server neu verbinden",
|
||||
app.reconnect,
|
||||
)
|
||||
|
||||
app.assignmentButton = widget.NewButton(
|
||||
"Gerätezuordnung",
|
||||
app.showAssignments,
|
||||
)
|
||||
|
||||
app.buildContent()
|
||||
|
||||
// Das Schließen des Hauptfensters wird abgefangen, damit ein noch
|
||||
// geöffnetes Zuordnungsfenster mit ungespeicherten Änderungen nicht
|
||||
// stillschweigend verworfen wird.
|
||||
window.SetCloseIntercept(
|
||||
app.handleMainWindowClose,
|
||||
)
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Main window
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// buildContent creates the main-window content.
|
||||
func (a *App) buildContent() {
|
||||
title := widget.NewLabel("rs2322tcp Client")
|
||||
|
||||
content := container.NewVBox(
|
||||
title,
|
||||
a.statusLabel,
|
||||
a.errorLabel,
|
||||
a.assignmentButton,
|
||||
a.reconnectButton,
|
||||
)
|
||||
|
||||
a.window.SetContent(content)
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Client lifecycle
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Start starts the technical client application in the background.
|
||||
//
|
||||
// The Fyne window remains responsive while the connection to the server is
|
||||
// established.
|
||||
func (a *App) Start() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
a.statusLabel.SetText(
|
||||
"Server: Verbindung wird aufgebaut ...",
|
||||
)
|
||||
a.errorLabel.SetText("")
|
||||
a.reconnectButton.Disable()
|
||||
|
||||
go func() {
|
||||
err := a.clientApplication.Start()
|
||||
|
||||
if err != nil {
|
||||
fyne.Do(func() {
|
||||
a.statusLabel.SetText(
|
||||
"Server: nicht verbunden",
|
||||
)
|
||||
a.errorLabel.SetText("")
|
||||
|
||||
a.showStartupError(err)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
devices := a.clientApplication.Devices()
|
||||
|
||||
fyne.Do(func() {
|
||||
a.statusLabel.SetText(
|
||||
fmt.Sprintf(
|
||||
"Server: verbunden – %d Geräte verfügbar",
|
||||
len(devices),
|
||||
),
|
||||
)
|
||||
|
||||
a.errorLabel.SetText("")
|
||||
a.reconnectButton.Enable()
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// reconnect starts a complete reconnect of the technical client.
|
||||
func (a *App) reconnect() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
a.statusLabel.SetText(
|
||||
"Server: Verbindung wird neu aufgebaut ...",
|
||||
)
|
||||
a.errorLabel.SetText("")
|
||||
a.reconnectButton.Disable()
|
||||
|
||||
go func() {
|
||||
err := a.clientApplication.Reconnect()
|
||||
|
||||
if err != nil {
|
||||
fyne.Do(func() {
|
||||
a.statusLabel.SetText(
|
||||
"Server: nicht verbunden",
|
||||
)
|
||||
a.errorLabel.SetText("")
|
||||
|
||||
a.showStartupError(err)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
devices := a.clientApplication.Devices()
|
||||
|
||||
fyne.Do(func() {
|
||||
a.statusLabel.SetText(
|
||||
fmt.Sprintf(
|
||||
"Server: verbunden – %d Geräte verfügbar",
|
||||
len(devices),
|
||||
),
|
||||
)
|
||||
|
||||
a.errorLabel.SetText("")
|
||||
a.reconnectButton.Enable()
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Startup error
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// showStartupError displays a modal error dialog for a client startup error.
|
||||
//
|
||||
// The dialog intentionally has only an "OK" button. A client configuration
|
||||
// error must not be bypassed because starting the client with inconsistent
|
||||
// virtual-port configuration could result in communication being directed
|
||||
// to the wrong or missing local interface.
|
||||
//
|
||||
// After the user acknowledges the error, the technical client is closed and
|
||||
// the complete Fyne application is terminated.
|
||||
func (a *App) showStartupError(err error) {
|
||||
if a == nil || a.window == nil || err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
title := widget.NewLabelWithStyle(
|
||||
"Die virtuellen Ports konnten nicht geprüft werden.",
|
||||
fyne.TextAlignLeading,
|
||||
fyne.TextStyle{
|
||||
Bold: true,
|
||||
},
|
||||
)
|
||||
|
||||
message := widget.NewLabel(
|
||||
err.Error(),
|
||||
)
|
||||
|
||||
message.Wrapping = fyne.TextWrapWord
|
||||
|
||||
content := container.NewVBox(
|
||||
title,
|
||||
widget.NewSeparator(),
|
||||
message,
|
||||
)
|
||||
|
||||
confirm := dialog.NewCustomWithoutButtons(
|
||||
"Virtuelle Ports",
|
||||
container.NewPadded(content),
|
||||
a.window,
|
||||
)
|
||||
|
||||
okButton := widget.NewButton(
|
||||
"OK",
|
||||
func() {
|
||||
confirm.Dismiss()
|
||||
|
||||
_ = a.clientApplication.Close()
|
||||
|
||||
a.window.SetCloseIntercept(nil)
|
||||
|
||||
a.fyneApp.Quit()
|
||||
},
|
||||
)
|
||||
|
||||
confirm.SetButtons(
|
||||
[]fyne.CanvasObject{
|
||||
okButton,
|
||||
},
|
||||
)
|
||||
|
||||
confirm.Show()
|
||||
|
||||
confirm.Resize(
|
||||
fyne.NewSize(650, 420),
|
||||
)
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Device assignment
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// showAssignments opens the editable device-assignment window.
|
||||
func (a *App) showAssignments() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Nur ein Zuordnungsfenster gleichzeitig.
|
||||
if a.assignmentWindow != nil {
|
||||
a.assignmentWindow.Show()
|
||||
return
|
||||
}
|
||||
|
||||
cfg := a.clientApplication.Config()
|
||||
devices := a.clientApplication.Devices()
|
||||
|
||||
editor := NewAssignmentEditor(
|
||||
cfg,
|
||||
devices,
|
||||
)
|
||||
|
||||
window := a.fyneApp.NewWindow(
|
||||
"Gerätezuordnung",
|
||||
)
|
||||
|
||||
window.Resize(
|
||||
fyne.NewSize(650, 450),
|
||||
)
|
||||
|
||||
a.assignmentWindow = window
|
||||
a.assignmentEditor = editor
|
||||
|
||||
// Beim Schließen über das Fenstersymbol wird dieselbe Logik wie beim
|
||||
// Button "Schließen" verwendet.
|
||||
window.SetCloseIntercept(
|
||||
func() {
|
||||
a.handleAssignmentWindowClose(
|
||||
window,
|
||||
editor,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
window.SetOnClosed(func() {
|
||||
if a.assignmentWindow == window {
|
||||
a.assignmentWindow = nil
|
||||
a.assignmentEditor = nil
|
||||
}
|
||||
})
|
||||
|
||||
a.refreshAssignmentWindow(
|
||||
window,
|
||||
editor,
|
||||
)
|
||||
|
||||
window.Show()
|
||||
}
|
||||
|
||||
// refreshAssignmentWindow rebuilds all assignment Select widgets.
|
||||
//
|
||||
// This is intentional. When a device is disconnected, its former port
|
||||
// becomes free and must immediately become selectable for the other
|
||||
// currently unconnected devices.
|
||||
func (a *App) refreshAssignmentWindow(
|
||||
window fyne.Window,
|
||||
editor *AssignmentEditor,
|
||||
) {
|
||||
if a == nil || window == nil || editor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIDs := editorDeviceIDs(editor)
|
||||
|
||||
objects := make(
|
||||
[]fyne.CanvasObject,
|
||||
0,
|
||||
len(deviceIDs)*2,
|
||||
)
|
||||
|
||||
objects = append(
|
||||
objects,
|
||||
widget.NewLabelWithStyle(
|
||||
"Server-Gerät",
|
||||
fyne.TextAlignLeading,
|
||||
fyne.TextStyle{Bold: true},
|
||||
),
|
||||
)
|
||||
|
||||
objects = append(
|
||||
objects,
|
||||
widget.NewLabelWithStyle(
|
||||
"Lokale Schnittstelle",
|
||||
fyne.TextAlignLeading,
|
||||
fyne.TextStyle{Bold: true},
|
||||
),
|
||||
)
|
||||
|
||||
for _, deviceID := range deviceIDs {
|
||||
deviceID := deviceID
|
||||
|
||||
deviceLabel := widget.NewLabel(deviceID)
|
||||
|
||||
selectBox := widget.NewSelect(
|
||||
editor.Options(deviceID),
|
||||
nil,
|
||||
)
|
||||
|
||||
currentPort := editor.CurrentPort(deviceID)
|
||||
|
||||
if currentPort == "" {
|
||||
selectBox.SetSelected(
|
||||
NotConnected,
|
||||
)
|
||||
} else {
|
||||
selectBox.SetSelected(
|
||||
currentPort,
|
||||
)
|
||||
}
|
||||
|
||||
selectBox.OnChanged = func(selected string) {
|
||||
if !editor.Set(
|
||||
deviceID,
|
||||
selected,
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Nach jeder Änderung werden sämtliche Selectboxen neu
|
||||
// aufgebaut. Dadurch werden gerade freigegebene Ports
|
||||
// sofort bei allen anderen Geräten sichtbar.
|
||||
a.refreshAssignmentWindow(
|
||||
window,
|
||||
editor,
|
||||
)
|
||||
}
|
||||
|
||||
objects = append(
|
||||
objects,
|
||||
deviceLabel,
|
||||
selectBox,
|
||||
)
|
||||
}
|
||||
|
||||
grid := container.New(
|
||||
layout.NewFormLayout(),
|
||||
objects...,
|
||||
)
|
||||
|
||||
info := widget.NewLabel(
|
||||
"Die Änderungen werden beim Schließen gespeichert.",
|
||||
)
|
||||
|
||||
closeButton := widget.NewButton(
|
||||
"Schließen",
|
||||
func() {
|
||||
a.handleAssignmentWindowClose(
|
||||
window,
|
||||
editor,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
footer := container.NewVBox(
|
||||
info,
|
||||
closeButton,
|
||||
)
|
||||
|
||||
window.SetContent(
|
||||
container.NewBorder(
|
||||
nil,
|
||||
footer,
|
||||
nil,
|
||||
nil,
|
||||
container.NewVScroll(grid),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// editorDeviceIDs returns all server-device IDs known by the assignment
|
||||
// editor in deterministic order.
|
||||
func editorDeviceIDs(
|
||||
editor *AssignmentEditor,
|
||||
) []string {
|
||||
if editor == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
deviceIDs := make(
|
||||
[]string,
|
||||
0,
|
||||
len(editor.Current),
|
||||
)
|
||||
|
||||
for deviceID := range editor.Current {
|
||||
deviceIDs = append(
|
||||
deviceIDs,
|
||||
deviceID,
|
||||
)
|
||||
}
|
||||
|
||||
sort.Strings(deviceIDs)
|
||||
|
||||
return deviceIDs
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Assignment saving
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// handleAssignmentWindowClose handles closing of the assignment window.
|
||||
//
|
||||
// The "Schließen" button saves changed assignments. If there are no changes,
|
||||
// the window is simply closed.
|
||||
func (a *App) handleAssignmentWindowClose(
|
||||
window fyne.Window,
|
||||
editor *AssignmentEditor,
|
||||
) {
|
||||
if a == nil || window == nil || editor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !editor.Dirty() {
|
||||
a.closeAssignmentWindowWithoutPrompt(
|
||||
window,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
a.saveAssignmentsAndClose(
|
||||
window,
|
||||
editor,
|
||||
)
|
||||
}
|
||||
|
||||
// saveAssignmentsAndClose converts the editor state into a client
|
||||
// configuration, saves it through client.Application and closes the
|
||||
// assignment window only after a successful save.
|
||||
func (a *App) saveAssignmentsAndClose(
|
||||
window fyne.Window,
|
||||
editor *AssignmentEditor,
|
||||
) {
|
||||
if a == nil || window == nil || editor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cfg := a.clientApplication.Config()
|
||||
if cfg == nil {
|
||||
dialog.ShowError(
|
||||
fmt.Errorf(
|
||||
"Client-Konfiguration ist nicht verfügbar",
|
||||
),
|
||||
window,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
updatedCfg := editor.ApplyToConfig(cfg)
|
||||
if updatedCfg == nil {
|
||||
dialog.ShowError(
|
||||
fmt.Errorf(
|
||||
"Gerätezuordnung konnte nicht übernommen werden",
|
||||
),
|
||||
window,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.clientApplication.SaveConfig(
|
||||
updatedCfg,
|
||||
); err != nil {
|
||||
dialog.ShowError(
|
||||
fmt.Errorf(
|
||||
"Client-Konfiguration konnte nicht gespeichert werden: %w",
|
||||
err,
|
||||
),
|
||||
window,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Erst nach erfolgreichem Speichern schließen.
|
||||
a.closeAssignmentWindowWithoutPrompt(
|
||||
window,
|
||||
)
|
||||
}
|
||||
|
||||
// closeAssignmentWindowWithoutPrompt closes the assignment window without
|
||||
// invoking its close intercept again.
|
||||
func (a *App) closeAssignmentWindowWithoutPrompt(
|
||||
window fyne.Window,
|
||||
) {
|
||||
if window == nil {
|
||||
return
|
||||
}
|
||||
|
||||
window.SetCloseIntercept(nil)
|
||||
window.Close()
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Main window closing
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// handleMainWindowClose handles closing of the main window.
|
||||
//
|
||||
// If the assignment window is open and contains unsaved changes, the user
|
||||
// must explicitly decide whether those changes should be discarded.
|
||||
func (a *App) handleMainWindowClose() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if a.assignmentEditor == nil ||
|
||||
!a.assignmentEditor.Dirty() {
|
||||
|
||||
a.finishMainWindowClose()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
confirm := dialog.NewConfirm(
|
||||
"Ungespeicherte Änderungen",
|
||||
"Die Gerätezuordnung enthält ungespeicherte "+
|
||||
"Änderungen.\n\n"+
|
||||
"Wenn das Hauptfenster geschlossen wird, "+
|
||||
"gehen diese Änderungen verloren.\n\n"+
|
||||
"Möchtest du die Änderungen verwerfen und "+
|
||||
"den Client schließen?",
|
||||
func(discard bool) {
|
||||
if !discard {
|
||||
return
|
||||
}
|
||||
|
||||
a.finishMainWindowClose()
|
||||
},
|
||||
a.window,
|
||||
)
|
||||
|
||||
confirm.SetConfirmText(
|
||||
"Änderungen verwerfen",
|
||||
)
|
||||
|
||||
confirm.SetDismissText(
|
||||
"Abbrechen",
|
||||
)
|
||||
|
||||
confirm.Show()
|
||||
}
|
||||
|
||||
// finishMainWindowClose closes the assignment window first and then the
|
||||
// main application window.
|
||||
//
|
||||
// The close intercepts are removed because the user has already confirmed
|
||||
// the operation.
|
||||
func (a *App) finishMainWindowClose() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if a.assignmentWindow != nil {
|
||||
window := a.assignmentWindow
|
||||
|
||||
window.SetCloseIntercept(nil)
|
||||
window.Close()
|
||||
|
||||
a.assignmentWindow = nil
|
||||
a.assignmentEditor = nil
|
||||
}
|
||||
|
||||
a.window.SetCloseIntercept(nil)
|
||||
a.window.Close()
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Public lifecycle
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Close closes the technical client application.
|
||||
//
|
||||
// This method is also used by cmd/rs2322tcp-client after the Fyne main
|
||||
// window has actually closed. Any remaining assignment window is closed
|
||||
// without prompting because the main-window close handling has already
|
||||
// taken place.
|
||||
func (a *App) Close() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if a.assignmentWindow != nil {
|
||||
window := a.assignmentWindow
|
||||
|
||||
window.SetCloseIntercept(nil)
|
||||
window.Close()
|
||||
|
||||
a.assignmentWindow = nil
|
||||
a.assignmentEditor = nil
|
||||
}
|
||||
|
||||
_ = a.clientApplication.Close()
|
||||
}
|
||||
|
||||
// ShowAndRun displays the main window and starts the Fyne event loop.
|
||||
func (a *App) ShowAndRun() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
|
||||
a.window.ShowAndRun()
|
||||
}
|
||||
|
||||
// Window returns the main application window.
|
||||
func (a *App) Window() fyne.Window {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return a.window
|
||||
}
|
||||
485
internal/gui/assignment.go
Normal file
485
internal/gui/assignment.go
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/gui/assignment.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* GUI-unabhängige Aufbereitung und Bearbeitung der Zuordnung zwischen den
|
||||
* vom rs2322tcp-Server angebotenen Geräten und den lokalen virtuellen
|
||||
* seriellen Schnittstellen.
|
||||
*
|
||||
* Die Datei enthält bewusst keine Fyne-Abhängigkeiten.
|
||||
*
|
||||
* Die Änderungen an der Gerätezuordnung werden zunächst ausschließlich im
|
||||
* Arbeitsspeicher des Zuordnungsfensters gehalten. Das Speichern in die
|
||||
* Client-Konfiguration erfolgt beim Schließen des Fensters.
|
||||
*
|
||||
* Regeln:
|
||||
*
|
||||
* - Ein Server-Gerät kann höchstens einer lokalen Schnittstelle zugeordnet
|
||||
* werden.
|
||||
*
|
||||
* - Eine lokale Schnittstelle kann höchstens einem Server-Gerät zugeordnet
|
||||
* werden.
|
||||
*
|
||||
* - "nicht verbunden" ist ein gültiger Zustand.
|
||||
*
|
||||
* - Ist ein Gerät bereits verbunden, kann es nur auf seinen aktuellen Port
|
||||
* oder auf "nicht verbunden" gesetzt werden.
|
||||
*
|
||||
* - Ist ein Gerät nicht verbunden, kann es auf "nicht verbunden" oder auf
|
||||
* einen momentan freien konfigurierten virtuellen Port gesetzt werden.
|
||||
*
|
||||
* - Wird ein Gerät getrennt, bleibt der virtuelle Port erhalten und wird
|
||||
* lediglich frei.
|
||||
* ============================================================================
|
||||
*/
|
||||
package gui
|
||||
|
||||
import (
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Constants
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const (
|
||||
// NotConnected is the GUI representation of an unassigned server device.
|
||||
NotConnected = "nicht verbunden"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Device assignment
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// DeviceAssignment beschreibt eine bereits in der Client-Konfiguration
|
||||
// vorhandene Zuordnung zwischen einer lokalen virtuellen Schnittstelle
|
||||
// und einem entfernten Gerät.
|
||||
//
|
||||
// Available gibt an, ob das konfigurierte Remote-Gerät momentan in der
|
||||
// vom Server gelieferten Geräteliste vorhanden ist.
|
||||
type DeviceAssignment struct {
|
||||
LocalPort string
|
||||
RemoteDevice string
|
||||
Available bool
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Assignment editor
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// AssignmentEditor enthält den momentan im GUI bearbeiteten
|
||||
// Zuordnungszustand.
|
||||
//
|
||||
// Original enthält den Zustand beim Öffnen des Fensters.
|
||||
//
|
||||
// Current enthält den momentan vom Anwender bearbeiteten Zustand.
|
||||
//
|
||||
// Der Schlüssel ist jeweils die stabile Remote-Device-ID. Ein leerer
|
||||
// Port bedeutet "nicht verbunden".
|
||||
//
|
||||
// Ports enthält alle konfigurierten virtuellen Ports. Diese Ports bleiben
|
||||
// auch dann erhalten, wenn sie momentan keinem Remote-Gerät zugeordnet sind.
|
||||
type AssignmentEditor struct {
|
||||
Original map[string]string
|
||||
Current map[string]string
|
||||
Ports []string
|
||||
}
|
||||
|
||||
// NewAssignmentEditor erzeugt einen bearbeitbaren Zuordnungszustand aus
|
||||
// der bestehenden Client-Konfiguration und der aktuellen Geräteliste.
|
||||
//
|
||||
// Es werden ausschließlich bereits konfigurierte virtuelle Ports betrachtet.
|
||||
// Das Anlegen neuer virtueller Ports erfolgt in einem späteren
|
||||
// Entwicklungsschritt.
|
||||
func NewAssignmentEditor(
|
||||
cfg *config.ClientConfig,
|
||||
devices []transport.RemoteDeviceInfo,
|
||||
) *AssignmentEditor {
|
||||
editor := &AssignmentEditor{
|
||||
Original: make(map[string]string),
|
||||
Current: make(map[string]string),
|
||||
Ports: make([]string, 0),
|
||||
}
|
||||
|
||||
if cfg != nil {
|
||||
for _, virtualPort := range cfg.VirtualPorts {
|
||||
if virtualPort.Port == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !containsString(
|
||||
editor.Ports,
|
||||
virtualPort.Port,
|
||||
) {
|
||||
editor.Ports = append(
|
||||
editor.Ports,
|
||||
virtualPort.Port,
|
||||
)
|
||||
}
|
||||
|
||||
if virtualPort.RemoteDevice == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
editor.Current[virtualPort.RemoteDevice] =
|
||||
virtualPort.Port
|
||||
}
|
||||
}
|
||||
|
||||
for _, device := range devices {
|
||||
if _, exists := editor.Current[device.ID]; !exists {
|
||||
editor.Current[device.ID] = ""
|
||||
}
|
||||
}
|
||||
|
||||
editor.Original = cloneAssignments(
|
||||
editor.Current,
|
||||
)
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
// CurrentPort returns the currently selected local port for a server device.
|
||||
//
|
||||
// An empty string means "nicht verbunden".
|
||||
func (e *AssignmentEditor) CurrentPort(
|
||||
deviceID string,
|
||||
) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return e.Current[deviceID]
|
||||
}
|
||||
|
||||
// Options returns the currently valid Selectbox options for one server
|
||||
// device.
|
||||
//
|
||||
// For an already connected device:
|
||||
//
|
||||
// - current port
|
||||
// - nicht verbunden
|
||||
//
|
||||
// For an unconnected device:
|
||||
//
|
||||
// - nicht verbunden
|
||||
// - all currently free configured virtual ports
|
||||
func (e *AssignmentEditor) Options(
|
||||
deviceID string,
|
||||
) []string {
|
||||
if e == nil {
|
||||
return []string{
|
||||
NotConnected,
|
||||
}
|
||||
}
|
||||
|
||||
currentPort := e.Current[deviceID]
|
||||
|
||||
if currentPort != "" {
|
||||
return []string{
|
||||
currentPort,
|
||||
NotConnected,
|
||||
}
|
||||
}
|
||||
|
||||
options := []string{
|
||||
NotConnected,
|
||||
}
|
||||
|
||||
for _, port := range e.Ports {
|
||||
if e.portUsedByOtherDevice(
|
||||
port,
|
||||
deviceID,
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
options = append(
|
||||
options,
|
||||
port,
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
// Set changes the current assignment of one server device.
|
||||
//
|
||||
// An empty port or NotConnected disconnects the device.
|
||||
//
|
||||
// A non-empty port is accepted only when it is a configured virtual port
|
||||
// and is not currently used by another server device.
|
||||
//
|
||||
// The function returns true when the current state was changed.
|
||||
func (e *AssignmentEditor) Set(
|
||||
deviceID string,
|
||||
port string,
|
||||
) bool {
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if port == NotConnected {
|
||||
port = ""
|
||||
}
|
||||
|
||||
currentPort := e.Current[deviceID]
|
||||
|
||||
if currentPort == port {
|
||||
return false
|
||||
}
|
||||
|
||||
if port != "" {
|
||||
if !containsString(
|
||||
e.Ports,
|
||||
port,
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if e.portUsedByOtherDevice(
|
||||
port,
|
||||
deviceID,
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
e.Current[deviceID] = port
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Dirty reports whether the current assignment state differs from the
|
||||
// state that was present when the editor was opened.
|
||||
func (e *AssignmentEditor) Dirty() bool {
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(e.Original) != len(e.Current) {
|
||||
return true
|
||||
}
|
||||
|
||||
for deviceID, originalPort := range e.Original {
|
||||
if e.Current[deviceID] != originalPort {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ApplyToConfig applies the current assignment state to a copy of the
|
||||
// supplied client configuration.
|
||||
//
|
||||
// Existing virtual ports are always preserved. Only their RemoteDevice
|
||||
// value is changed.
|
||||
//
|
||||
// An empty RemoteDevice means that the virtual port currently has no
|
||||
// server-device assignment.
|
||||
//
|
||||
// The original configuration is not modified.
|
||||
func (e *AssignmentEditor) ApplyToConfig(
|
||||
cfg *config.ClientConfig,
|
||||
) *config.ClientConfig {
|
||||
if e == nil || cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := *cfg
|
||||
|
||||
result.VirtualPorts = make(
|
||||
[]config.VirtualPortConfig,
|
||||
len(cfg.VirtualPorts),
|
||||
)
|
||||
|
||||
for i, virtualPort := range cfg.VirtualPorts {
|
||||
result.VirtualPorts[i] = virtualPort
|
||||
|
||||
// Every existing virtual port remains in the configuration.
|
||||
// It becomes unassigned unless the current GUI state assigns it
|
||||
// to a server device.
|
||||
result.VirtualPorts[i].RemoteDevice = ""
|
||||
|
||||
for deviceID, port := range e.Current {
|
||||
if port == virtualPort.Port {
|
||||
result.VirtualPorts[i].RemoteDevice = deviceID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &result
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Build assignments
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// BuildAssignments erzeugt die Zuordnungsansicht aus der bestehenden
|
||||
// Client-Konfiguration und der aktuell vom Server gelieferten Geräteliste.
|
||||
//
|
||||
// Es werden ausschließlich die in der Client-Konfiguration vorhandenen
|
||||
// lokalen virtuellen Ports berücksichtigt.
|
||||
func BuildAssignments(
|
||||
cfg *config.ClientConfig,
|
||||
devices []transport.RemoteDeviceInfo,
|
||||
) []DeviceAssignment {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
assignments := make(
|
||||
[]DeviceAssignment,
|
||||
0,
|
||||
len(cfg.VirtualPorts),
|
||||
)
|
||||
|
||||
for _, virtualPort := range cfg.VirtualPorts {
|
||||
assignments = append(
|
||||
assignments,
|
||||
DeviceAssignment{
|
||||
LocalPort: virtualPort.Port,
|
||||
RemoteDevice: virtualPort.RemoteDevice,
|
||||
Available: remoteDeviceExists(
|
||||
virtualPort.RemoteDevice,
|
||||
devices,
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return assignments
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Unassigned devices
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// UnassignedDevices liefert die vom Server angebotenen Geräte, die aktuell
|
||||
// keiner lokalen virtuellen Schnittstelle zugeordnet sind.
|
||||
func UnassignedDevices(
|
||||
cfg *config.ClientConfig,
|
||||
devices []transport.RemoteDeviceInfo,
|
||||
) []transport.RemoteDeviceInfo {
|
||||
assigned := assignedRemoteDevices(
|
||||
cfg,
|
||||
)
|
||||
|
||||
unassigned := make(
|
||||
[]transport.RemoteDeviceInfo,
|
||||
0,
|
||||
)
|
||||
|
||||
for _, device := range devices {
|
||||
if assigned[device.ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
unassigned = append(
|
||||
unassigned,
|
||||
device,
|
||||
)
|
||||
}
|
||||
|
||||
return unassigned
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Internal helpers
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// portUsedByOtherDevice prüft, ob ein Port momentan einem anderen
|
||||
// Server-Gerät zugeordnet ist.
|
||||
func (e *AssignmentEditor) portUsedByOtherDevice(
|
||||
port string,
|
||||
deviceID string,
|
||||
) bool {
|
||||
for otherDeviceID, assignedPort := range e.Current {
|
||||
if otherDeviceID == deviceID {
|
||||
continue
|
||||
}
|
||||
|
||||
if assignedPort == port {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// assignedRemoteDevices erzeugt eine Menge der Remote-Geräte, die bereits
|
||||
// einer lokalen virtuellen Schnittstelle zugeordnet sind.
|
||||
func assignedRemoteDevices(
|
||||
cfg *config.ClientConfig,
|
||||
) map[string]bool {
|
||||
assigned := make(
|
||||
map[string]bool,
|
||||
)
|
||||
|
||||
if cfg == nil {
|
||||
return assigned
|
||||
}
|
||||
|
||||
for _, virtualPort := range cfg.VirtualPorts {
|
||||
if virtualPort.RemoteDevice == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
assigned[virtualPort.RemoteDevice] = true
|
||||
}
|
||||
|
||||
return assigned
|
||||
}
|
||||
|
||||
// remoteDeviceExists prüft, ob ein Remote-Gerät mit der angegebenen ID in
|
||||
// der aktuellen Geräteliste des Servers vorhanden ist.
|
||||
func remoteDeviceExists(
|
||||
id string,
|
||||
devices []transport.RemoteDeviceInfo,
|
||||
) bool {
|
||||
for _, device := range devices {
|
||||
if device.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// cloneAssignments erzeugt eine unabhängige Kopie einer Zuordnungskarte.
|
||||
func cloneAssignments(
|
||||
assignments map[string]string,
|
||||
) map[string]string {
|
||||
clone := make(
|
||||
map[string]string,
|
||||
len(assignments),
|
||||
)
|
||||
|
||||
for deviceID, port := range assignments {
|
||||
clone[deviceID] = port
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
// containsString prüft, ob ein String in einer Liste enthalten ist.
|
||||
func containsString(
|
||||
values []string,
|
||||
value string,
|
||||
) bool {
|
||||
for _, current := range values {
|
||||
if current == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
374
internal/gui/assignment_test.go
Normal file
374
internal/gui/assignment_test.go
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/gui/assignment_test.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Tests für die GUI-unabhängige Aufbereitung der Zuordnung zwischen lokalen
|
||||
* virtuellen seriellen Schnittstellen und den vom rs2322tcp-Server
|
||||
* angebotenen Geräten.
|
||||
* ============================================================================
|
||||
*/
|
||||
package gui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Test helpers
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// testClientConfig creates a client configuration containing the supplied
|
||||
// local-to-remote assignments.
|
||||
func testClientConfig(
|
||||
assignments ...config.VirtualPortConfig,
|
||||
) *config.ClientConfig {
|
||||
return &config.ClientConfig{
|
||||
VirtualPorts: assignments,
|
||||
}
|
||||
}
|
||||
|
||||
// testRemoteDevices creates a list of remote devices from their IDs.
|
||||
func testRemoteDevices(ids ...string) []transport.RemoteDeviceInfo {
|
||||
devices := make([]transport.RemoteDeviceInfo, 0, len(ids))
|
||||
|
||||
for _, id := range ids {
|
||||
devices = append(devices, transport.RemoteDeviceInfo{
|
||||
ID: id,
|
||||
Name: id,
|
||||
})
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// BuildAssignments tests
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func TestBuildAssignments(t *testing.T) {
|
||||
cfg := testClientConfig(
|
||||
config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
RemoteDevice: "radio",
|
||||
},
|
||||
config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB101",
|
||||
RemoteDevice: "rotor",
|
||||
},
|
||||
)
|
||||
|
||||
devices := testRemoteDevices(
|
||||
"radio",
|
||||
"rotor",
|
||||
"gps",
|
||||
)
|
||||
|
||||
assignments := BuildAssignments(cfg, devices)
|
||||
|
||||
if len(assignments) != 2 {
|
||||
t.Fatalf(
|
||||
"BuildAssignments() returned %d assignments, want 2",
|
||||
len(assignments),
|
||||
)
|
||||
}
|
||||
|
||||
if assignments[0].LocalPort != "/dev/ttyUSB100" {
|
||||
t.Errorf(
|
||||
"assignments[0].LocalPort = %q, want %q",
|
||||
assignments[0].LocalPort,
|
||||
"/dev/ttyUSB100",
|
||||
)
|
||||
}
|
||||
|
||||
if assignments[0].RemoteDevice != "radio" {
|
||||
t.Errorf(
|
||||
"assignments[0].RemoteDevice = %q, want %q",
|
||||
assignments[0].RemoteDevice,
|
||||
"radio",
|
||||
)
|
||||
}
|
||||
|
||||
if !assignments[0].Available {
|
||||
t.Error("assignments[0].Available = false, want true")
|
||||
}
|
||||
|
||||
if assignments[1].LocalPort != "/dev/ttyUSB101" {
|
||||
t.Errorf(
|
||||
"assignments[1].LocalPort = %q, want %q",
|
||||
assignments[1].LocalPort,
|
||||
"/dev/ttyUSB101",
|
||||
)
|
||||
}
|
||||
|
||||
if assignments[1].RemoteDevice != "rotor" {
|
||||
t.Errorf(
|
||||
"assignments[1].RemoteDevice = %q, want %q",
|
||||
assignments[1].RemoteDevice,
|
||||
"rotor",
|
||||
)
|
||||
}
|
||||
|
||||
if !assignments[1].Available {
|
||||
t.Error("assignments[1].Available = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAssignmentsUnavailableDevice(t *testing.T) {
|
||||
cfg := testClientConfig(
|
||||
config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
RemoteDevice: "radio",
|
||||
},
|
||||
config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB101",
|
||||
RemoteDevice: "rotor",
|
||||
},
|
||||
)
|
||||
|
||||
devices := testRemoteDevices("rotor")
|
||||
|
||||
assignments := BuildAssignments(cfg, devices)
|
||||
|
||||
if len(assignments) != 2 {
|
||||
t.Fatalf(
|
||||
"BuildAssignments() returned %d assignments, want 2",
|
||||
len(assignments),
|
||||
)
|
||||
}
|
||||
|
||||
if assignments[0].RemoteDevice != "radio" {
|
||||
t.Errorf(
|
||||
"assignments[0].RemoteDevice = %q, want %q",
|
||||
assignments[0].RemoteDevice,
|
||||
"radio",
|
||||
)
|
||||
}
|
||||
|
||||
if assignments[0].Available {
|
||||
t.Error("radio assignment marked available, want unavailable")
|
||||
}
|
||||
|
||||
if assignments[1].RemoteDevice != "rotor" {
|
||||
t.Errorf(
|
||||
"assignments[1].RemoteDevice = %q, want %q",
|
||||
assignments[1].RemoteDevice,
|
||||
"rotor",
|
||||
)
|
||||
}
|
||||
|
||||
if !assignments[1].Available {
|
||||
t.Error("rotor assignment marked unavailable, want available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAssignmentsNilConfig(t *testing.T) {
|
||||
devices := testRemoteDevices("radio")
|
||||
|
||||
assignments := BuildAssignments(nil, devices)
|
||||
|
||||
if assignments != nil {
|
||||
t.Fatalf(
|
||||
"BuildAssignments(nil, ...) = %#v, want nil",
|
||||
assignments,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAssignmentsEmptyConfig(t *testing.T) {
|
||||
cfg := testClientConfig()
|
||||
|
||||
devices := testRemoteDevices(
|
||||
"radio",
|
||||
"rotor",
|
||||
)
|
||||
|
||||
assignments := BuildAssignments(cfg, devices)
|
||||
|
||||
if len(assignments) != 0 {
|
||||
t.Fatalf(
|
||||
"BuildAssignments() returned %d assignments, want 0",
|
||||
len(assignments),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// UnassignedDevices tests
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func TestUnassignedDevices(t *testing.T) {
|
||||
cfg := testClientConfig(
|
||||
config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
RemoteDevice: "radio",
|
||||
},
|
||||
config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB101",
|
||||
RemoteDevice: "rotor",
|
||||
},
|
||||
)
|
||||
|
||||
devices := testRemoteDevices(
|
||||
"radio",
|
||||
"rotor",
|
||||
"gps",
|
||||
"tnc",
|
||||
)
|
||||
|
||||
unassigned := UnassignedDevices(cfg, devices)
|
||||
|
||||
if len(unassigned) != 2 {
|
||||
t.Fatalf(
|
||||
"UnassignedDevices() returned %d devices, want 2",
|
||||
len(unassigned),
|
||||
)
|
||||
}
|
||||
|
||||
if unassigned[0].ID != "gps" {
|
||||
t.Errorf(
|
||||
"unassigned[0].ID = %q, want %q",
|
||||
unassigned[0].ID,
|
||||
"gps",
|
||||
)
|
||||
}
|
||||
|
||||
if unassigned[1].ID != "tnc" {
|
||||
t.Errorf(
|
||||
"unassigned[1].ID = %q, want %q",
|
||||
unassigned[1].ID,
|
||||
"tnc",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnassignedDevicesAllAssigned(t *testing.T) {
|
||||
cfg := testClientConfig(
|
||||
config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
RemoteDevice: "radio",
|
||||
},
|
||||
config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB101",
|
||||
RemoteDevice: "rotor",
|
||||
},
|
||||
)
|
||||
|
||||
devices := testRemoteDevices(
|
||||
"radio",
|
||||
"rotor",
|
||||
)
|
||||
|
||||
unassigned := UnassignedDevices(cfg, devices)
|
||||
|
||||
if len(unassigned) != 0 {
|
||||
t.Fatalf(
|
||||
"UnassignedDevices() returned %d devices, want 0",
|
||||
len(unassigned),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnassignedDevicesEmptyConfig(t *testing.T) {
|
||||
cfg := testClientConfig()
|
||||
|
||||
devices := testRemoteDevices(
|
||||
"radio",
|
||||
"rotor",
|
||||
)
|
||||
|
||||
unassigned := UnassignedDevices(cfg, devices)
|
||||
|
||||
if len(unassigned) != 2 {
|
||||
t.Fatalf(
|
||||
"UnassignedDevices() returned %d devices, want 2",
|
||||
len(unassigned),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnassignedDevicesNilConfig(t *testing.T) {
|
||||
devices := testRemoteDevices(
|
||||
"radio",
|
||||
"rotor",
|
||||
)
|
||||
|
||||
unassigned := UnassignedDevices(nil, devices)
|
||||
|
||||
if len(unassigned) != 2 {
|
||||
t.Fatalf(
|
||||
"UnassignedDevices(nil, ...) returned %d devices, want 2",
|
||||
len(unassigned),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnassignedDevicesEmptyServerList(t *testing.T) {
|
||||
cfg := testClientConfig(
|
||||
config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
RemoteDevice: "radio",
|
||||
},
|
||||
)
|
||||
|
||||
unassigned := UnassignedDevices(cfg, nil)
|
||||
|
||||
if len(unassigned) != 0 {
|
||||
t.Fatalf(
|
||||
"UnassignedDevices() returned %d devices, want 0",
|
||||
len(unassigned),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Existing configuration does not change
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func TestAssignmentFunctionsDoNotModifyConfig(t *testing.T) {
|
||||
cfg := testClientConfig(
|
||||
config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
RemoteDevice: "radio",
|
||||
},
|
||||
)
|
||||
|
||||
devices := testRemoteDevices(
|
||||
"radio",
|
||||
"rotor",
|
||||
"gps",
|
||||
)
|
||||
|
||||
_ = BuildAssignments(cfg, devices)
|
||||
_ = UnassignedDevices(cfg, devices)
|
||||
|
||||
if len(cfg.VirtualPorts) != 1 {
|
||||
t.Fatalf(
|
||||
"configuration contains %d virtual ports, want 1",
|
||||
len(cfg.VirtualPorts),
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.VirtualPorts[0].Port != "/dev/ttyUSB100" {
|
||||
t.Errorf(
|
||||
"configuration port = %q, want %q",
|
||||
cfg.VirtualPorts[0].Port,
|
||||
"/dev/ttyUSB100",
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.VirtualPorts[0].RemoteDevice != "radio" {
|
||||
t.Errorf(
|
||||
"configuration remote device = %q, want %q",
|
||||
cfg.VirtualPorts[0].RemoteDevice,
|
||||
"radio",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ package serial
|
|||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
bugserial "go.bug.st/serial"
|
||||
|
||||
|
|
@ -28,6 +29,7 @@ import (
|
|||
|
||||
// Connection represents an opened serial connection.
|
||||
type Connection struct {
|
||||
mu sync.Mutex
|
||||
port bugserial.Port
|
||||
}
|
||||
|
||||
|
|
@ -126,20 +128,36 @@ func createMode(device config.DeviceConfig) (*bugserial.Mode, error) {
|
|||
|
||||
// Read reads data from the serial connection.
|
||||
func (c *Connection) Read(p []byte) (int, error) {
|
||||
if c == nil || c.port == nil {
|
||||
if c == nil {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
return c.port.Read(p)
|
||||
c.mu.Lock()
|
||||
port := c.port
|
||||
c.mu.Unlock()
|
||||
|
||||
if port == nil {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
return port.Read(p)
|
||||
}
|
||||
|
||||
// Write writes data to the serial connection.
|
||||
func (c *Connection) Write(p []byte) (int, error) {
|
||||
if c == nil || c.port == nil {
|
||||
if c == nil {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
return c.port.Write(p)
|
||||
c.mu.Lock()
|
||||
port := c.port
|
||||
c.mu.Unlock()
|
||||
|
||||
if port == nil {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
return port.Write(p)
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -148,12 +166,21 @@ func (c *Connection) Write(p []byte) (int, error) {
|
|||
|
||||
// Close closes the serial connection.
|
||||
func (c *Connection) Close() error {
|
||||
if c == nil || c.port == nil {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := c.port.Close()
|
||||
c.mu.Lock()
|
||||
|
||||
if c.port == nil {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
port := c.port
|
||||
c.port = nil
|
||||
|
||||
return err
|
||||
c.mu.Unlock()
|
||||
|
||||
return port.Close()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -336,6 +336,7 @@ func (s *ControlServer) runDataListener(
|
|||
dataConnection, err := NewDataConnection(
|
||||
tcpConn,
|
||||
serialConn,
|
||||
s.config.HardwareErrorResponse,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf(
|
||||
|
|
@ -351,6 +352,11 @@ func (s *ControlServer) runDataListener(
|
|||
continue
|
||||
}
|
||||
|
||||
dataConnection.SetSerialMonitor(
|
||||
s.config.SerialMonitor,
|
||||
device.SerialPort,
|
||||
)
|
||||
|
||||
if err := session.AddDataConnection(
|
||||
device.ID,
|
||||
dataConnection,
|
||||
|
|
@ -362,6 +368,7 @@ func (s *ControlServer) runDataListener(
|
|||
err,
|
||||
)
|
||||
|
||||
_ = dataConnection.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ func testServerConfig() *config.ServerConfig {
|
|||
Address: "127.0.0.1",
|
||||
Port: 5000,
|
||||
},
|
||||
HardwareErrorResponse: testHardwareErrorResponse,
|
||||
Devices: []config.DeviceConfig{
|
||||
{
|
||||
ID: "radio",
|
||||
|
|
@ -451,6 +452,7 @@ func TestControlServerDataConnection(t *testing.T) {
|
|||
Address: "127.0.0.1",
|
||||
Port: 5000,
|
||||
},
|
||||
HardwareErrorResponse: testHardwareErrorResponse,
|
||||
Devices: []config.DeviceConfig{
|
||||
{
|
||||
ID: "radio",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: data_connection.go
|
||||
* Datei.......: internal/server/data_connection.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
|
@ -16,6 +16,7 @@ package server
|
|||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
)
|
||||
|
|
@ -31,12 +32,20 @@ import (
|
|||
// TCP -> Serial
|
||||
// TCP <- Serial
|
||||
//
|
||||
// If writing TCP data to the serial device fails, the configured hardware
|
||||
// error response is sent back over the TCP connection before the connection
|
||||
// is terminated.
|
||||
//
|
||||
// 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
|
||||
tcp net.Conn
|
||||
serial io.ReadWriteCloser
|
||||
hardwareErrorResponse string
|
||||
|
||||
serialMonitor bool
|
||||
serialPort string
|
||||
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
|
|
@ -50,6 +59,7 @@ type DataConnection struct {
|
|||
func NewDataConnection(
|
||||
tcp net.Conn,
|
||||
serial io.ReadWriteCloser,
|
||||
hardwareErrorResponse string,
|
||||
) (*DataConnection, error) {
|
||||
if tcp == nil {
|
||||
return nil, fmt.Errorf("TCP connection is nil")
|
||||
|
|
@ -59,9 +69,14 @@ func NewDataConnection(
|
|||
return nil, fmt.Errorf("serial connection is nil")
|
||||
}
|
||||
|
||||
if hardwareErrorResponse == "" {
|
||||
return nil, fmt.Errorf("hardware error response is empty")
|
||||
}
|
||||
|
||||
return &DataConnection{
|
||||
tcp: tcp,
|
||||
serial: serial,
|
||||
tcp: tcp,
|
||||
serial: serial,
|
||||
hardwareErrorResponse: hardwareErrorResponse,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +102,153 @@ 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
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// copyTCPToSerial transfers data from the TCP connection to the serial
|
||||
// device.
|
||||
//
|
||||
// If writing to the serial device fails, the configured hardware error
|
||||
// response is sent back to the TCP client before the transfer terminates.
|
||||
func (c *DataConnection) copyTCPToSerial() error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("data connection is nil")
|
||||
}
|
||||
|
||||
buffer := make([]byte, 32*1024)
|
||||
|
||||
for {
|
||||
n, err := c.tcp.Read(buffer)
|
||||
|
||||
if n > 0 {
|
||||
c.logTCPData("TCP RX", buffer[:n])
|
||||
|
||||
written, writeErr := c.serial.Write(buffer[:n])
|
||||
|
||||
if writeErr != nil {
|
||||
_, _ = io.WriteString(
|
||||
c.tcp,
|
||||
c.hardwareErrorResponse,
|
||||
)
|
||||
|
||||
return writeErr
|
||||
}
|
||||
|
||||
if written != n {
|
||||
_, _ = io.WriteString(
|
||||
c.tcp,
|
||||
c.hardwareErrorResponse,
|
||||
)
|
||||
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
|
||||
c.logSerialData("SERIAL TX", buffer[:n])
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// copySerialToTCP transfers data from the serial device to the TCP
|
||||
// connection.
|
||||
func (c *DataConnection) copySerialToTCP() error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("data connection is nil")
|
||||
}
|
||||
|
||||
buffer := make([]byte, 32*1024)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Run
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -96,6 +258,10 @@ func (c *DataConnection) SerialConn() io.ReadWriteCloser {
|
|||
// Run blocks until one of the two transfer directions terminates.
|
||||
// The other direction is then stopped and both connections are closed.
|
||||
//
|
||||
// If writing data from TCP to the serial device fails, the configured
|
||||
// hardware error response is sent to the TCP client before the connection
|
||||
// is closed.
|
||||
//
|
||||
// The first non-EOF transfer error is returned.
|
||||
func (c *DataConnection) Run() error {
|
||||
if c == nil {
|
||||
|
|
@ -111,15 +277,13 @@ func (c *DataConnection) Run() error {
|
|||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
_, err := io.Copy(c.serial, c.tcp)
|
||||
errCh <- err
|
||||
errCh <- c.copyTCPToSerial()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
_, err := io.Copy(c.tcp, c.serial)
|
||||
errCh <- err
|
||||
errCh <- c.copySerialToTCP()
|
||||
}()
|
||||
|
||||
err := <-errCh
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ package server_test
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
|
|
@ -31,6 +32,8 @@ type testSerialConnection struct {
|
|||
reader *bytes.Reader
|
||||
writer bytes.Buffer
|
||||
|
||||
writeErr error
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
|
||||
|
|
@ -45,6 +48,14 @@ func newTestSerialConnection(data []byte) *testSerialConnection {
|
|||
}
|
||||
}
|
||||
|
||||
func newFailingTestSerialConnection(writeErr error) *testSerialConnection {
|
||||
return &testSerialConnection{
|
||||
reader: bytes.NewReader(nil),
|
||||
writeErr: writeErr,
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *testSerialConnection) Read(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
|
||||
|
|
@ -76,6 +87,10 @@ func (s *testSerialConnection) Write(p []byte) (int, error) {
|
|||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
if s.writeErr != nil {
|
||||
return 0, s.writeErr
|
||||
}
|
||||
|
||||
return s.writer.Write(p)
|
||||
}
|
||||
|
||||
|
|
@ -116,6 +131,7 @@ func TestNewDataConnection(t *testing.T) {
|
|||
connection, err := server.NewDataConnection(
|
||||
tcpServer,
|
||||
serial,
|
||||
testHardwareErrorResponse,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDataConnection() failed: %v", err)
|
||||
|
|
@ -136,6 +152,7 @@ func TestNewDataConnectionRejectsNilTCP(t *testing.T) {
|
|||
connection, err := server.NewDataConnection(
|
||||
nil,
|
||||
serial,
|
||||
testHardwareErrorResponse,
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
|
|
@ -155,6 +172,7 @@ func TestNewDataConnectionRejectsNilSerial(t *testing.T) {
|
|||
connection, err := server.NewDataConnection(
|
||||
tcpServer,
|
||||
nil,
|
||||
testHardwareErrorResponse,
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
|
|
@ -179,6 +197,7 @@ func TestDataConnectionTCPToSerial(t *testing.T) {
|
|||
connection, err := server.NewDataConnection(
|
||||
tcpServer,
|
||||
serial,
|
||||
testHardwareErrorResponse,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDataConnection() failed: %v", err)
|
||||
|
|
@ -223,6 +242,60 @@ func TestDataConnectionTCPToSerial(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// TCP -> Serial error
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func TestDataConnectionTCPToSerialWriteError(t *testing.T) {
|
||||
tcpServer, tcpClient := net.Pipe()
|
||||
defer tcpClient.Close()
|
||||
|
||||
writeErr := errors.New("hardware write failed")
|
||||
|
||||
serial := newFailingTestSerialConnection(writeErr)
|
||||
|
||||
connection, err := server.NewDataConnection(
|
||||
tcpServer,
|
||||
serial,
|
||||
testHardwareErrorResponse,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDataConnection() failed: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
done <- connection.Run()
|
||||
}()
|
||||
|
||||
testData := []byte("hello unavailable hardware")
|
||||
|
||||
if _, err := tcpClient.Write(testData); err != nil {
|
||||
t.Fatalf("TCP Write() failed: %v", err)
|
||||
}
|
||||
|
||||
errorResponse := make([]byte, len(testHardwareErrorResponse))
|
||||
|
||||
if _, err := io.ReadFull(tcpClient, errorResponse); err != nil {
|
||||
t.Fatalf("TCP Read() failed: %v", err)
|
||||
}
|
||||
|
||||
if string(errorResponse) != testHardwareErrorResponse {
|
||||
t.Fatalf(
|
||||
"error response = %q, want %q",
|
||||
string(errorResponse),
|
||||
testHardwareErrorResponse,
|
||||
)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("DataConnection.Run() did not terminate")
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Serial -> TCP
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -238,6 +311,7 @@ func TestDataConnectionSerialToTCP(t *testing.T) {
|
|||
connection, err := server.NewDataConnection(
|
||||
tcpServer,
|
||||
serial,
|
||||
testHardwareErrorResponse,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDataConnection() failed: %v", err)
|
||||
|
|
@ -285,6 +359,7 @@ func TestDataConnectionClose(t *testing.T) {
|
|||
connection, err := server.NewDataConnection(
|
||||
tcpServer,
|
||||
serial,
|
||||
testHardwareErrorResponse,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDataConnection() failed: %v", err)
|
||||
|
|
|
|||
|
|
@ -38,8 +38,9 @@ type SerialFactory func() (io.ReadWriteCloser, error)
|
|||
// DataHandler accepts TCP data connections and connects them to a serial
|
||||
// device.
|
||||
type DataHandler struct {
|
||||
listener *DataListener
|
||||
serialFactory SerialFactory
|
||||
listener *DataListener
|
||||
serialFactory SerialFactory
|
||||
hardwareErrorResponse string
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -53,6 +54,7 @@ type DataHandler struct {
|
|||
func NewDataHandler(
|
||||
listener *DataListener,
|
||||
serialFactory SerialFactory,
|
||||
hardwareErrorResponse string,
|
||||
) (*DataHandler, error) {
|
||||
if listener == nil {
|
||||
return nil, fmt.Errorf("data listener is nil")
|
||||
|
|
@ -62,9 +64,14 @@ func NewDataHandler(
|
|||
return nil, fmt.Errorf("serial factory is nil")
|
||||
}
|
||||
|
||||
if hardwareErrorResponse == "" {
|
||||
return nil, fmt.Errorf("hardware error response is empty")
|
||||
}
|
||||
|
||||
return &DataHandler{
|
||||
listener: listener,
|
||||
serialFactory: serialFactory,
|
||||
listener: listener,
|
||||
serialFactory: serialFactory,
|
||||
hardwareErrorResponse: hardwareErrorResponse,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -114,6 +121,7 @@ func (h *DataHandler) handleConnection(tcpConn net.Conn) {
|
|||
dataConnection, err := NewDataConnection(
|
||||
tcpConn,
|
||||
serialConn,
|
||||
h.hardwareErrorResponse,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("create data connection: %v", err)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ import (
|
|||
"git.lang-dieter.de/rs2322tcp/internal/server"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Test constants
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const testHardwareErrorResponse = "TEST ERROR RESPONSE"
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Test serial connection
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -119,6 +125,7 @@ func TestNewDataHandler(t *testing.T) {
|
|||
func() (io.ReadWriteCloser, error) {
|
||||
return serial, nil
|
||||
},
|
||||
testHardwareErrorResponse,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDataHandler() failed: %v", err)
|
||||
|
|
@ -135,6 +142,7 @@ func TestNewDataHandlerRejectsNilListener(t *testing.T) {
|
|||
func() (io.ReadWriteCloser, error) {
|
||||
return newHandlerTestSerial(nil), nil
|
||||
},
|
||||
testHardwareErrorResponse,
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
|
|
@ -156,6 +164,7 @@ func TestNewDataHandlerRejectsNilFactory(t *testing.T) {
|
|||
handler, err := server.NewDataHandler(
|
||||
listener,
|
||||
nil,
|
||||
testHardwareErrorResponse,
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
|
|
@ -185,6 +194,7 @@ func TestDataHandlerTCPToSerial(t *testing.T) {
|
|||
func() (io.ReadWriteCloser, error) {
|
||||
return serial, nil
|
||||
},
|
||||
testHardwareErrorResponse,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDataHandler() failed: %v", err)
|
||||
|
|
@ -255,6 +265,7 @@ func TestDataHandlerSerialToTCP(t *testing.T) {
|
|||
func() (io.ReadWriteCloser, error) {
|
||||
return serial, nil
|
||||
},
|
||||
testHardwareErrorResponse,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDataHandler() failed: %v", err)
|
||||
|
|
@ -311,6 +322,7 @@ func TestDataHandlerSerialFactoryError(t *testing.T) {
|
|||
func() (io.ReadWriteCloser, error) {
|
||||
return nil, io.ErrClosedPipe
|
||||
},
|
||||
testHardwareErrorResponse,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDataHandler() failed: %v", err)
|
||||
|
|
|
|||
12
scripts/test-rotor-com10.bat
Normal file
12
scripts/test-rotor-com10.bat
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
@echo off
|
||||
cd /d "%~dp0.."
|
||||
|
||||
echo ==========================================
|
||||
echo rs2322tcp COM10 Testsequenz
|
||||
echo ==========================================
|
||||
echo.
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\test-rotor-com10.ps1"
|
||||
|
||||
echo.
|
||||
pause
|
||||
80
scripts/test-rotor-com10.ps1
Normal file
80
scripts/test-rotor-com10.ps1
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
$port = New-Object System.IO.Ports.SerialPort COM10,600,None,8,one
|
||||
|
||||
try {
|
||||
$port.Open()
|
||||
|
||||
[byte[]]$request = 0x57,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x1F,0x20
|
||||
|
||||
$port.Write($request, 0, $request.Length)
|
||||
|
||||
$response = New-Object System.Collections.Generic.List[byte]
|
||||
|
||||
$timeout = [DateTime]::UtcNow.AddSeconds(2)
|
||||
|
||||
while ([DateTime]::UtcNow -lt $timeout) {
|
||||
|
||||
while ($port.BytesToRead -gt 0) {
|
||||
$buffer = New-Object byte[] $port.BytesToRead
|
||||
$count = $port.Read($buffer, 0, $buffer.Length)
|
||||
|
||||
for ($i = 0; $i -lt $count; $i++) {
|
||||
$response.Add($buffer[$i])
|
||||
}
|
||||
}
|
||||
|
||||
# Auf das Startbyte 57 synchronisieren.
|
||||
$start = $response.IndexOf([byte]0x57)
|
||||
|
||||
if ($start -ge 0 -and ($response.Count - $start) -ge 12) {
|
||||
$response = [System.Collections.Generic.List[byte]](
|
||||
$response.GetRange($start, 12)
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 20
|
||||
}
|
||||
|
||||
if ($response.Count -eq 12) {
|
||||
$actual = ($response |
|
||||
ForEach-Object { "{0:X2}" -f $_ }) -join " "
|
||||
|
||||
$expected = "57 07 01 06 00 01 03 06 02 00 01 20"
|
||||
|
||||
Write-Host "Bytes empfangen: $($response.Count)"
|
||||
Write-Host "Antwort: $actual"
|
||||
|
||||
if ($actual -eq $expected) {
|
||||
Write-Host "TEST ERFOLGREICH"
|
||||
}
|
||||
else {
|
||||
Write-Host "TEST FEHLGESCHLAGEN"
|
||||
Write-Host "Erwartet: $expected"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "Bytes empfangen: $($response.Count)"
|
||||
|
||||
if ($response.Count -gt 0) {
|
||||
$actual = ($response |
|
||||
ForEach-Object { "{0:X2}" -f $_ }) -join " "
|
||||
Write-Host "Antwort: $actual"
|
||||
}
|
||||
else {
|
||||
Write-Host "Keine Antwort"
|
||||
}
|
||||
|
||||
Write-Host "TEST FEHLGESCHLAGEN - keine vollstaendige Antwort gefunden"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($port.IsOpen) {
|
||||
$port.Close()
|
||||
}
|
||||
|
||||
$port.Dispose()
|
||||
$port = $null
|
||||
|
||||
[GC]::Collect()
|
||||
[GC]::WaitForPendingFinalizers()
|
||||
}
|
||||
20
scripts/test-windows-client.bat
Normal file
20
scripts/test-windows-client.bat
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
@echo off
|
||||
cd /d "%~dp0.."
|
||||
|
||||
echo ==========================================
|
||||
echo rs2322tcp Windows Client Test
|
||||
echo ==========================================
|
||||
echo.
|
||||
echo Starte normalen rs2322tcp-Client...
|
||||
echo COM100 wird vom Client verwendet.
|
||||
echo Die konfigurierte Startsequenz wird nach
|
||||
echo erfolgreichem Verbindungsaufbau automatisch gesendet.
|
||||
echo.
|
||||
echo Zum Beenden: Ctrl+C
|
||||
echo.
|
||||
|
||||
go run .\cmd\rs2322tcp-client
|
||||
|
||||
echo.
|
||||
echo Client beendet.
|
||||
pause
|
||||
Loading…
Reference in a new issue