Compare commits
26 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea8913fb69 | ||
|
|
27b97316fc | ||
|
|
8b7d081960 | ||
| da212e943b | |||
| 3a85b17422 | |||
|
|
c9e3ba052a | ||
| d890aba7b4 | |||
| 0eccd003f0 | |||
| f2428a2007 | |||
| 21d622e040 | |||
|
|
d9a70b9225 | ||
|
|
61bd03b863 | ||
| a6cd04ece4 | |||
| 39a1fea75d | |||
|
|
81b06a16a0 | ||
|
|
90189ab4dc | ||
|
|
5e595fe460 | ||
|
|
8d98e85453 | ||
|
|
1621ed4330 | ||
|
|
a33546fbed | ||
|
|
084ca66c7b | ||
|
|
80e4196e16 | ||
|
|
f3610b989c | ||
|
|
9d5425975b | ||
|
|
1d3239da71 | ||
|
|
ea4499b93f |
68 changed files with 11104 additions and 421 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -1,7 +1,14 @@
|
|||
Notizen.txt
|
||||
|
||||
# Build-Verzeichnis
|
||||
/build/
|
||||
|
||||
# Distributions-Verzeichnis
|
||||
/dist/
|
||||
|
||||
# Temporäre Arbeitsdateien von fyne-cross
|
||||
/fyne-cross/
|
||||
|
||||
# Lokale Build-Binaries
|
||||
/rs2322tcp-client
|
||||
/rs2322tcp-server
|
||||
|
|
|
|||
398
CHANGELOG.md
398
CHANGELOG.md
|
|
@ -2,58 +2,350 @@
|
|||
|
||||
Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert.
|
||||
|
||||
## [Unreleased]
|
||||
## [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
|
||||
- Linux-PTY-Unterstützung für die clientseitige virtuelle serielle Schnittstelle
|
||||
|
||||
- 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
|
||||
|
||||
- Clientseitige Runtime zur Verwaltung mehrerer virtueller serieller Ports
|
||||
- Verbindung konfigurierter virtueller Ports mit den vom Server bereitgestellten
|
||||
Remote-Geräten
|
||||
- Clientseitige TCP-Data-Connections zu den dynamischen Data-Ports des Servers
|
||||
- Bidirektionale Datenübertragung über eine `Bridge`
|
||||
- Verwaltung des Lebenszyklus von virtuellen Ports, Bridges und Client-Verbindung
|
||||
- Integrationstest für den vollständigen Datenweg zwischen virtuellem seriellen
|
||||
Port und Server
|
||||
- Tests für Verbindungsaufbau, Gerätezuordnung und Runtime-Lebenszyklus
|
||||
|
||||
### Tests
|
||||
|
||||
- `go test ./...` erfolgreich
|
||||
- `go test -race ./...` erfolgreich
|
||||
- Bidirektionale Übertragung über Runtime, Bridge, TCP und PTY erfolgreich
|
||||
- Race Conditions in Client-Control-Verbindung, PTY und virtuellen Ports behoben
|
||||
## [0.0.5] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- Konfigurierbarer Bereich für lokale virtuelle serielle Schnittstellen
|
||||
- Standardbereich für virtuelle Ports von `/dev/ttyUSB100` bis
|
||||
`/dev/ttyUSB199`
|
||||
- Abweichender Portbereich kann über `virtual_port_range` in der
|
||||
Client-Konfiguration festgelegt werden
|
||||
- Rückwärtskompatibilität für bestehende Client-Konfigurationen ohne
|
||||
`virtual_port_range`
|
||||
- Verwaltung virtueller Ports über `VirtualPortManager`
|
||||
- Automatische Auswahl des nächsten freien virtuellen Ports
|
||||
- Freigabe reservierter Ports bei deren Schließen
|
||||
- Linux-spezifische Verwaltung der virtuellen Port-Symlinks unter
|
||||
`~/.rs2322tcp/virtual/`
|
||||
- Verknüpfung der virtuellen `/dev/ttyUSBxxx`-Schnittstellen mit den
|
||||
vom Client erzeugten PTYs
|
||||
- Einführung von `VirtualPort` und `ManagedVirtualPort`
|
||||
- Umfangreiche Tests für Portauswahl, Portfreigabe, Symlink-Verwaltung
|
||||
und Linux-PTY-Integration
|
||||
|
||||
### Tests
|
||||
|
||||
- `go test ./...` erfolgreich
|
||||
- `go test -race ./...` erfolgreich
|
||||
- Bidirektionale Kommunikation über `/dev/ttyUSB100` erfolgreich getestet
|
||||
- Externe Software kann ausschließlich `/dev/ttyUSB100` verwenden
|
||||
- Übertragung vom virtuellen `/dev/ttyUSB100` zum Client erfolgreich getestet
|
||||
- Übertragung vom Client zum virtuellen `/dev/ttyUSB100` erfolgreich getestet
|
||||
- Zweistufige Symlink-Kette von `/dev/ttyUSB100` über
|
||||
`~/.rs2322tcp/virtual/ttyUSB100` bis zum PTY erfolgreich getestet
|
||||
|
||||
---
|
||||
|
||||
## [0.0.4] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- Interaktiver Geräte-Simulator `rs2322tcp-device-sim`
|
||||
- Senden beliebiger Bytefolgen über eine interaktive Hex-Eingabe
|
||||
- Ausgabe empfangener Daten als Hex-Dump
|
||||
- Unterstützung von Hex-Eingaben mit und ohne Leerzeichen
|
||||
- Fehlerbehandlung für ungültige Hex-Eingaben
|
||||
- Automatisierte Tests für die Hex-Konvertierung
|
||||
- Erweiterte Dokumentation des Geräte-Simulators und des PTY-Testaufbaus
|
||||
- Möglichkeit, den Simulator als virtuelle serielle Gegenstelle für
|
||||
externe Software zu verwenden
|
||||
|
||||
### Tests
|
||||
|
||||
- Byte-orientierte Übertragung mit beliebigen Binärdaten getestet
|
||||
- Übertragung aller Bytewerte `00` bis `FF` getestet
|
||||
- Übertragung vom PTY-Slave zum PTY-Master getestet
|
||||
- Übertragung vom PTY-Master zum PTY-Slave getestet
|
||||
- `go test ./...` erfolgreich
|
||||
- `go test -race ./...` erfolgreich
|
||||
|
||||
---
|
||||
|
||||
## [0.0.3] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- Linux-PTY-Unterstützung für die clientseitige virtuelle serielle
|
||||
Schnittstelle
|
||||
- Abstraktion der virtuellen seriellen Schnittstelle über `VirtualSerial`
|
||||
- Automatisierter bidirektionaler PTY-Test mit allen 256 möglichen Bytewerten
|
||||
- Eigenständiges Programm `rs2322tcp-device-sim` als Simulator für ein angeschlossenes serielles Gerät
|
||||
- Eigenständiges Programm `rs2322tcp-device-sim` als Simulator für ein
|
||||
angeschlossenes serielles Gerät
|
||||
- Hexadezimale Darstellung der vom Geräte-Simulator empfangenen Rohdaten
|
||||
|
||||
### Tests
|
||||
|
||||
- PTY-Slave und PTY-Master erfolgreich miteinander getestet
|
||||
- Bidirektionale Übertragung von Binärdaten getestet
|
||||
- Bytefolgen mit Werten von `00` bis `FF` erfolgreich übertragen
|
||||
- Empfang beliebiger Datenblöcke über den PTY-Master getestet
|
||||
|
||||
---
|
||||
|
||||
## [0.0.2] - 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- Implementierung der seriellen Schnittstellenschicht unter `internal/serial`
|
||||
- Verwendung von `go.bug.st/serial v1.7.1` für die Ansteuerung der seriellen Schnittstellen
|
||||
- Konfigurationsunterstützung für Baudrate, Datenbits, Parität und Stopbits
|
||||
- Öffnen und Schließen serieller Schnittstellen über die interne Serial-Abstraktion
|
||||
- Lesen und Schreiben von RS232-Daten über die Serial-Abstraktion
|
||||
- Dynamische TCP-Data-Listener für konfigurierte Geräte
|
||||
- Session-Verwaltung für dynamische Data-Listener
|
||||
- Session-Verwaltung für aktive DataConnections
|
||||
- Bidirektionale Datenübertragung zwischen TCP und RS232
|
||||
- Öffnen der seriellen Schnittstelle erst beim Aufbau einer tatsächlichen Data-Verbindung
|
||||
- Begrenzung auf eine aktive Data-Verbindung pro konfiguriertem Gerät
|
||||
- Integrationstest mit virtuellen seriellen Schnittstellen über `socat`
|
||||
- Tests für TCP → RS232 und RS232 → TCP
|
||||
- Tests für Session-Reconnect und Ressourcenverwaltung
|
||||
- Serielle Geräte können über Konfiguration definiert werden
|
||||
- Server-Control-Verbindung
|
||||
- Session-Verwaltung
|
||||
- Dynamische Data-Ports pro Gerät
|
||||
- Serverseitiger Aufbau der TCP-Data-Verbindung
|
||||
- `DataConnection` für die bidirektionale Datenübertragung
|
||||
- `DataListener` zur Annahme von Data-Verbindungen
|
||||
- TCP → RS232 Datenübertragung
|
||||
- RS232 → TCP Datenübertragung
|
||||
- Client-Control-Verbindung unter `internal/client`
|
||||
- Clientseitige Verarbeitung von `HELLO`
|
||||
- Clientseitige Abfrage der konfigurierten Geräte über `GET_DEVICES`
|
||||
- Clientseitige Verarbeitung der vom Server gelieferten Geräteinformationen einschließlich dynamischem Data-Port
|
||||
- Clientseitige Verarbeitung der vom Server gelieferten Geräteinformationen
|
||||
einschließlich dynamischem Data-Port
|
||||
- Clientseitiger Aufbau der TCP-Data-Verbindung
|
||||
- Integrationstest für die vollständige Client-/Server-Kommunikation
|
||||
- End-to-End-Test der TCP-/RS232-Datenübertragung in beide Richtungen
|
||||
|
||||
### Changed
|
||||
|
||||
- Go-Version des Projektes auf Go `1.25.0` aktualisiert
|
||||
- `go.bug.st/serial` wird in Version `v1.7.1` verwendet
|
||||
- `golang.org/x/sys v0.43.0` wird als indirekte Abhängigkeit verwendet
|
||||
- Die Serial-Bibliothek `go.bug.st/serial` wurde zusätzlich auf dem privaten Git-Server des Projektes als Ausfallsicherung gespiegelt:
|
||||
`git.lang-dieter.de/third-party/go-serial`
|
||||
- Der `DataListener` wurde nebenläufigkeitssicher implementiert
|
||||
- Das Schließen eines `DataListener` kann gleichzeitig mit einem laufenden `Accept()` erfolgen
|
||||
- Serial-Kommunikation vollständig über `go.bug.st/serial`
|
||||
- Server öffnet serielle Schnittstellen erst bei aktiver Data-Verbindung
|
||||
- Pro Gerät wird nur eine aktive Data-Verbindung zugelassen
|
||||
- Session- und Ressourcenverwaltung überarbeitet
|
||||
- Race Condition im `DataListener` behoben
|
||||
|
||||
### Tests
|
||||
|
||||
- `go test ./...` erfolgreich
|
||||
- `go test -race ./...` erfolgreich
|
||||
- Race Condition im `DataListener` erkannt und behoben
|
||||
- Unit-Tests für Serial
|
||||
- Unit-Tests für Session
|
||||
- Unit-Tests für DataConnection
|
||||
- Unit-Tests für DataListener
|
||||
- Integrationstest mit virtuellen seriellen Schnittstellen über `socat`
|
||||
- Tests für TCP → RS232 und RS232 → TCP
|
||||
- Tests für Session-Reconnect und Ressourcenverwaltung
|
||||
- Client-/Server-Integrationstest erfolgreich
|
||||
- Race-Test des Client-/Server-Integrationstests erfolgreich
|
||||
- Automatisierter bidirektionaler PTY-Test mit einem vollständigen Bytebereich von `0x00` bis `0xFF`
|
||||
- Race-Detection für die neue Linux-PTY-Komponente erfolgreich
|
||||
|
||||
### Status
|
||||
|
||||
Die grundlegende TCP-/RS232-Datenübertragung ist serverseitig implementiert und der Client kann inzwischen die Control-Verbindung sowie die zugehörige Data-Verbindung aufbauen.
|
||||
Die grundlegende TCP-/RS232-Datenübertragung ist serverseitig implementiert
|
||||
und der Client kann die Control-Verbindung sowie die zugehörige
|
||||
Data-Verbindung aufbauen.
|
||||
|
||||
Der aktuelle Datenpfad ist:
|
||||
|
||||
|
|
@ -103,49 +395,19 @@ Der Geräte-Simulator ist als eigenständiges Testprogramm vorhanden und kann
|
|||
eine serielle Schnittstelle öffnen und empfangene Rohdaten hexadezimal
|
||||
anzeigen.
|
||||
|
||||
Die TX-Funktion des Geräte-Simulators sowie die vollständige Integration der
|
||||
virtuellen seriellen Schnittstelle in den Client-Datenpfad sind noch nicht
|
||||
implementiert.
|
||||
Die interaktive TX-Funktion des Geräte-Simulators sowie die vollständige
|
||||
Integration der virtuellen seriellen Schnittstelle in den produktiven
|
||||
Client-Datenpfad sind noch nicht abgeschlossen.
|
||||
|
||||
---
|
||||
|
||||
## [0.0.1] - 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- Grundstruktur des Projektes `rs2322tcp`
|
||||
- Go-Modul `git.lang-dieter.de/rs2322tcp`
|
||||
- separates Programmgerüst für `rs2322tcp-client`
|
||||
- separates Programmgerüst für `rs2322tcp-server`
|
||||
- gemeinsame Versionierungsinformationen
|
||||
- Git-basierte Build-Informationen
|
||||
- zentrales Build-Skript `scripts/build.sh`
|
||||
- automatische Quellcode-Formatierung beim Build
|
||||
- automatische Tests beim Build
|
||||
- SHA256-Prüfsummen der erzeugten Binärdateien
|
||||
- `.gitignore` für Build- und Entwicklungsdateien
|
||||
- erste Projekt- und Architekturdokumentation
|
||||
|
||||
### Build-Ziele
|
||||
|
||||
Der erste Build unterstützt:
|
||||
|
||||
- Linux amd64 – Client und Server
|
||||
- Linux arm64 – Server
|
||||
- Windows amd64 – Client
|
||||
|
||||
### Architekturentscheidungen
|
||||
|
||||
- TCP wird für die Übertragung der RS232-Daten verwendet.
|
||||
- Windows und Linux werden als gleichberechtigte Client-Plattformen betrachtet.
|
||||
- Der Server ist zunächst für den Raspberry Pi 5 vorgesehen.
|
||||
- Tailscale wird als vertrauenswürdiges Netzwerk verwendet.
|
||||
- Eine zusätzliche TLS-Schicht ist zunächst nicht vorgesehen.
|
||||
- Für eine spätere Audioübertragung ist ein separates UDP-basiertes Transportkonzept vorgesehen.
|
||||
- `socat` kann für Entwicklung und Diagnose eingesetzt werden.
|
||||
- Eine Sniffer-/Logging-Funktion für den übertragenen Datenverkehr ist vorgesehen.
|
||||
|
||||
### Status
|
||||
|
||||
Version `0.0.1` enthält ausschließlich die Projektgrundlage.
|
||||
|
||||
Die eigentliche TCP-/RS232-Kommunikation und die virtuelle serielle Schnittstelle sind noch nicht implementiert.
|
||||
- Initiale Projektstruktur
|
||||
- Go-Modul und Build-Grundlagen
|
||||
- Basis für Server und Client
|
||||
- Konfigurationsgrundlagen
|
||||
- Versionsverwaltung und Build-Metadaten
|
||||
- Erste Dokumentation
|
||||
|
|
|
|||
BIN
Icon.png
Normal file
BIN
Icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,24 +7,37 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Einfacher Simulator für ein serielles Endgerät.
|
||||
* Interaktiver Simulator für ein serielles Endgerät.
|
||||
*
|
||||
* Der Simulator öffnet eine angegebene serielle Schnittstelle und protokolliert
|
||||
* alle vom angeschlossenen System empfangenen Bytes als Hexdump.
|
||||
*
|
||||
* Zusätzlich können über die Standardeingabe hexadezimal angegebene Bytefolgen
|
||||
* an das serielle Gerät gesendet werden. Dadurch kann der Simulator sowohl als
|
||||
* passiver Sniffer als auch als einfaches interaktives Testgerät verwendet
|
||||
* werden.
|
||||
*
|
||||
* Der Simulator stellt bewusst keine eigene PTY-Erzeugung bereit. Für
|
||||
* Entwicklung und Integrationstests kann ihm beispielsweise ein unter Linux
|
||||
* erzeugter PTY-Slave (/dev/pts/N) übergeben werden. Im späteren Testaufbau
|
||||
* kann derselbe Simulator jedoch auch direkt mit einer echten seriellen
|
||||
* Schnittstelle verbunden werden.
|
||||
*
|
||||
* Der Simulator dient zunächst als passives Gegenstück zum
|
||||
* rs2322tcp-server. Er soll insbesondere ermöglichen, die vom Server an ein
|
||||
* simuliertes Gerät übertragenen Bytes sichtbar zu machen.
|
||||
* Der Simulator dient zunächst als externes Gegenstück zum
|
||||
* rs2322tcp-server. Er ermöglicht insbesondere, die vom Server an ein
|
||||
* simuliertes Gerät übertragenen Bytes sichtbar zu machen und umgekehrt
|
||||
* definierte Bytefolgen an den Server zurückzusenden.
|
||||
*
|
||||
* Zukünftige Erweiterungen können unter anderem das manuelle Senden von
|
||||
* Bytes, automatische Geräteantworten, Protokollsimulation und das Aufzeichnen
|
||||
* bzw. Wiedergeben von Datenströmen umfassen.
|
||||
* Eingabeformat:
|
||||
*
|
||||
* TX> 46 41 00 10 0D
|
||||
*
|
||||
* Die Leerzeichen zwischen den Bytes sind optional. Groß- und Kleinschreibung
|
||||
* der Hex-Ziffern spielt keine Rolle.
|
||||
*
|
||||
* Zukünftige Erweiterungen können unter anderem automatische Geräteantworten,
|
||||
* Protokollsimulation und das Aufzeichnen bzw. Wiedergeben von Datenströmen
|
||||
* umfassen.
|
||||
*
|
||||
* Aufruf:
|
||||
*
|
||||
|
|
@ -36,12 +49,16 @@
|
|||
* Port: /dev/pts/7
|
||||
* Warte auf Daten ...
|
||||
*
|
||||
* Empfangene Daten werden mit Zeitstempel, Richtung und Byteanzahl ausgegeben:
|
||||
* Empfangene und gesendete Daten werden mit Zeitstempel, Richtung und
|
||||
* Byteanzahl ausgegeben:
|
||||
*
|
||||
* 12:34:01.123 RX 5 Bytes
|
||||
* 46 41 00 10 0D
|
||||
*
|
||||
* Der Simulator interpretiert die empfangenen Daten nicht. Die Darstellung
|
||||
* 12:34:05.456 TX 5 Bytes
|
||||
* 46 41 01 10 0D
|
||||
*
|
||||
* Der Simulator interpretiert die seriellen Daten nicht. Die Darstellung
|
||||
* erfolgt ausschließlich als Rohdaten in hexadezimaler Form.
|
||||
*
|
||||
* ============================================================================
|
||||
|
|
@ -50,10 +67,14 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
|
@ -92,6 +113,13 @@ func main() {
|
|||
fmt.Printf("Port: %s\n", *port)
|
||||
fmt.Println("Warte auf Daten ...")
|
||||
fmt.Println()
|
||||
fmt.Println("Hex-Bytes senden mit:")
|
||||
fmt.Println("TX> 46 41 00 10 0D")
|
||||
fmt.Println()
|
||||
|
||||
// Die Standardeingabe wird parallel zur seriellen Schnittstelle gelesen.
|
||||
// Dadurch kann der Simulator gleichzeitig Daten empfangen und senden.
|
||||
go readTX(fd)
|
||||
|
||||
// Der Simulator arbeitet byteorientiert. Die Größe des Puffers ist
|
||||
// bewusst ausreichend groß gewählt, damit auch größere Datenblöcke
|
||||
|
|
@ -135,15 +163,130 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
// readTX liest Zeilen von stdin und interpretiert sie als Folge von
|
||||
// hexadezimal angegebenen Bytes.
|
||||
//
|
||||
// Die Eingabe wird bewusst zeilenorientiert verarbeitet. Dadurch kann eine
|
||||
// komplette Bytefolge zuerst geprüft werden, bevor sie an die serielle
|
||||
// Schnittstelle gesendet wird.
|
||||
func readTX(fd int) {
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
|
||||
fmt.Print("TX> ")
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
if line == "" {
|
||||
fmt.Print("TX> ")
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := parseHexBytes(line)
|
||||
if err != nil {
|
||||
fmt.Printf("Ungültige Hex-Eingabe: %v\n", err)
|
||||
fmt.Print("TX> ")
|
||||
continue
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
fmt.Print("TX> ")
|
||||
continue
|
||||
}
|
||||
|
||||
if err := writeAll(fd, data); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Senden: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
printTX(data)
|
||||
fmt.Print("TX> ")
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Lesen von stdin: %v\n", err)
|
||||
}
|
||||
|
||||
// Bei EOF (Ctrl+D) wird der Dateideskriptor geschlossen. Dadurch wird
|
||||
// der blockierende Lesevorgang im Haupt-Goroutine beendet und der
|
||||
// Simulator kann sauber verlassen werden.
|
||||
_ = unix.Close(fd)
|
||||
}
|
||||
|
||||
// parseHexBytes wandelt eine zeilenorientierte Hex-Eingabe in einen
|
||||
// Byte-Block um.
|
||||
//
|
||||
// Leerzeichen zwischen den Bytes sind erlaubt und werden entfernt. Dadurch
|
||||
// werden beispielsweise sowohl
|
||||
//
|
||||
// 46 41 00 10 0D
|
||||
//
|
||||
// als auch
|
||||
//
|
||||
// 464100100D
|
||||
//
|
||||
// akzeptiert. Die Funktion selbst enthält keine Ausgabe und ist deshalb
|
||||
// unabhängig vom interaktiven Terminalbetrieb testbar.
|
||||
func parseHexBytes(line string) ([]byte, error) {
|
||||
fields := strings.Fields(line)
|
||||
hexText := strings.Join(fields, "")
|
||||
|
||||
if hexText == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := hex.DecodeString(hexText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// writeAll schreibt alle Bytes des angegebenen Datenblocks auf den seriellen
|
||||
// Dateideskriptor. unix.Write() darf weniger Bytes als angefordert schreiben.
|
||||
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
|
||||
}
|
||||
|
||||
// printRX gibt einen empfangenen Datenblock als Hexdump aus.
|
||||
//
|
||||
// Die Daten werden absichtlich nicht als Text interpretiert. Dadurch bleiben
|
||||
// auch nicht druckbare Bytes und Bytes oberhalb von 0x7F sichtbar und können
|
||||
// bei der Analyse serieller Protokolle eindeutig nachvollzogen werden.
|
||||
func printRX(data []byte) {
|
||||
printHexDump("RX", data)
|
||||
}
|
||||
|
||||
// printTX gibt einen gesendeten Datenblock als Hexdump aus.
|
||||
func printTX(data []byte) {
|
||||
printHexDump("TX", data)
|
||||
}
|
||||
|
||||
// printHexDump gibt einen Datenblock mit Zeitstempel, Richtung und Byteanzahl
|
||||
// als Hexdump aus.
|
||||
func printHexDump(direction string, data []byte) {
|
||||
fmt.Printf(
|
||||
"%s RX %d Bytes\n",
|
||||
"%s %s %d Bytes\n",
|
||||
time.Now().Format("15:04:05.000"),
|
||||
direction,
|
||||
len(data),
|
||||
)
|
||||
|
||||
|
|
|
|||
92
cmd/rs2322tcp-device-sim/main_test.go
Normal file
92
cmd/rs2322tcp-device-sim/main_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: main_test.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Unit-Tests für die Hex-Eingabe des Geräte-Simulators.
|
||||
*
|
||||
* Getestet werden gültige Bytefolgen, optionale Leerzeichen, Groß- und
|
||||
* Kleinschreibung sowie typische ungültige Eingaben.
|
||||
*
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseHexBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "mehrere Bytes mit Leerzeichen",
|
||||
input: "46 41 00 10 0D",
|
||||
want: []byte{0x46, 0x41, 0x00, 0x10, 0x0D},
|
||||
},
|
||||
{
|
||||
name: "mehrere Bytes ohne Leerzeichen",
|
||||
input: "464100100D",
|
||||
want: []byte{0x46, 0x41, 0x00, 0x10, 0x0D},
|
||||
},
|
||||
{
|
||||
name: "gemischte Groß- und Kleinschreibung",
|
||||
input: "0a FF 01 b7",
|
||||
want: []byte{0x0A, 0xFF, 0x01, 0xB7},
|
||||
},
|
||||
{
|
||||
name: "ein Byte",
|
||||
input: "00",
|
||||
want: []byte{0x00},
|
||||
},
|
||||
{
|
||||
name: "leere Eingabe",
|
||||
input: "",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "nur Leerzeichen",
|
||||
input: " ",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "ungültiges Hex-Zeichen",
|
||||
input: "GG",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "ungerade Hex-Länge",
|
||||
input: "4",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "dreistellige Eingabe",
|
||||
input: "100",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseHexBytes(tt.input)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("Fehlerstatus: got %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
if !bytes.Equal(got, tt.want) {
|
||||
t.Fatalf("Ergebnis: got % X, want % X", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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,16 +1,18 @@
|
|||
{
|
||||
"server": {
|
||||
"address": "100.64.0.10",
|
||||
"address": "127.0.0.1",
|
||||
"port": 5000
|
||||
},
|
||||
"virtual_port_range": {
|
||||
"first": 100,
|
||||
"last": 199
|
||||
},
|
||||
"virtual_ports": [
|
||||
{
|
||||
"port": "COM7",
|
||||
"remote_device": "radio"
|
||||
},
|
||||
{
|
||||
"port": "COM8",
|
||||
"remote_device": "rotor"
|
||||
"port": "/dev/ttyUSB100",
|
||||
"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
|
||||
130
internal/client/bridge.go
Normal file
130
internal/client/bridge.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/bridge.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Bidirektionale Daten-Bridge zwischen der virtuellen seriellen
|
||||
* Schnittstelle des Clients und der TCP-Data-Verbindung.
|
||||
*
|
||||
* Die Bridge transportiert den Byte-Strom in beide Richtungen:
|
||||
*
|
||||
* VirtualSerial ─────► TCP
|
||||
* VirtualSerial ◄───── TCP
|
||||
*
|
||||
* Die Bridge kennt weder das Control-Protokoll noch die konkrete
|
||||
* serielle Hardware. Sie verbindet ausschließlich zwei io.ReadWriter-
|
||||
* Endpunkte miteinander.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Bridge
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Bridge verbindet eine virtuelle serielle Schnittstelle mit einer
|
||||
// TCP-Data-Verbindung.
|
||||
//
|
||||
// Beide Datenrichtungen werden gleichzeitig bedient. Dadurch bleibt der
|
||||
// serielle Datenstrom vollständig bidirektional.
|
||||
type Bridge struct {
|
||||
serial VirtualSerial
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Constructor
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// NewBridge creates a new bidirectional data bridge.
|
||||
//
|
||||
// The bridge does not take ownership of the endpoints until Run is called.
|
||||
func NewBridge(serial VirtualSerial, conn net.Conn) (*Bridge, error) {
|
||||
if serial == nil {
|
||||
return nil, fmt.Errorf("virtual serial is nil")
|
||||
}
|
||||
|
||||
if conn == nil {
|
||||
return nil, fmt.Errorf("TCP connection is nil")
|
||||
}
|
||||
|
||||
return &Bridge{
|
||||
serial: serial,
|
||||
conn: conn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Run
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Run starts the bidirectional byte transfer and blocks until one of the
|
||||
// directions terminates.
|
||||
//
|
||||
// Closing the endpoints after termination releases the opposite blocked
|
||||
// transfer as well.
|
||||
func (b *Bridge) Run() error {
|
||||
if b == nil || b.serial == nil {
|
||||
return fmt.Errorf("bridge is not initialized")
|
||||
}
|
||||
|
||||
if b.conn == nil {
|
||||
return fmt.Errorf("TCP connection is nil")
|
||||
}
|
||||
|
||||
type result struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
results := make(chan result, 2)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
_, err := io.Copy(b.conn, b.serial)
|
||||
results <- result{
|
||||
name: "serial to TCP",
|
||||
err: err,
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
_, err := io.Copy(b.serial, b.conn)
|
||||
results <- result{
|
||||
name: "TCP to serial",
|
||||
err: err,
|
||||
}
|
||||
}()
|
||||
|
||||
first := <-results
|
||||
|
||||
// Beide Richtungen sollen beendet werden. Das Schließen der Endpunkte
|
||||
// unterbricht insbesondere einen eventuell noch blockierenden Read.
|
||||
_ = b.conn.Close()
|
||||
_ = b.serial.Close()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if first.err != nil {
|
||||
return fmt.Errorf("%s: %w", first.name, first.err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
271
internal/client/bridge_test.go
Normal file
271
internal/client/bridge_test.go
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/bridge_test.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Tests für die bidirektionale Daten-Bridge zwischen VirtualSerial und
|
||||
* TCP-Verbindung.
|
||||
*
|
||||
* Die Tests verwenden net.Pipe und eine kleine In-Memory-Implementierung
|
||||
* von VirtualSerial. Dadurch wird ausschließlich die Daten-Bridge getestet,
|
||||
* ohne echte TCP-Ports oder eine reale PTY-Schnittstelle zu benötigen.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Test VirtualSerial
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type testVirtualSerial struct {
|
||||
mu sync.Mutex
|
||||
reader *bytes.Reader
|
||||
writes [][]byte
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newTestVirtualSerial(data []byte) *testVirtualSerial {
|
||||
return &testVirtualSerial{
|
||||
reader: bytes.NewReader(data),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *testVirtualSerial) Path() string {
|
||||
return "test-serial"
|
||||
}
|
||||
|
||||
func (s *testVirtualSerial) Read(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.closed {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
return s.reader.Read(p)
|
||||
}
|
||||
|
||||
func (s *testVirtualSerial) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.closed {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
cp := append([]byte(nil), p...)
|
||||
s.writes = append(s.writes, cp)
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (s *testVirtualSerial) Written() []byte {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var result []byte
|
||||
for _, p := range s.writes {
|
||||
result = append(result, p...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *testVirtualSerial) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Blocking test VirtualSerial
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type blockingVirtualSerial struct {
|
||||
mu sync.Mutex
|
||||
writes [][]byte
|
||||
readCh chan struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newBlockingVirtualSerial() *blockingVirtualSerial {
|
||||
return &blockingVirtualSerial{
|
||||
readCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *blockingVirtualSerial) Path() string {
|
||||
return "blocking-test-serial"
|
||||
}
|
||||
|
||||
func (s *blockingVirtualSerial) Read([]byte) (int, error) {
|
||||
<-s.readCh
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (s *blockingVirtualSerial) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.closed {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
s.writes = append(s.writes, append([]byte(nil), p...))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (s *blockingVirtualSerial) Written() []byte {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var result []byte
|
||||
for _, p := range s.writes {
|
||||
result = append(result, p...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *blockingVirtualSerial) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.closed {
|
||||
s.closed = true
|
||||
close(s.readCh)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Tests
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func TestNewBridge(t *testing.T) {
|
||||
serial := newTestVirtualSerial(nil)
|
||||
connA, connB := net.Pipe()
|
||||
defer connA.Close()
|
||||
defer connB.Close()
|
||||
|
||||
if _, err := NewBridge(nil, connA); err == nil {
|
||||
t.Fatal("expected error for nil VirtualSerial")
|
||||
}
|
||||
|
||||
if _, err := NewBridge(serial, nil); err == nil {
|
||||
t.Fatal("expected error for nil TCP connection")
|
||||
}
|
||||
|
||||
bridge, err := NewBridge(serial, connA)
|
||||
if err != nil {
|
||||
t.Fatalf("NewBridge: %v", err)
|
||||
}
|
||||
|
||||
if bridge.serial != serial {
|
||||
t.Fatal("bridge serial endpoint mismatch")
|
||||
}
|
||||
|
||||
if bridge.conn != connA {
|
||||
t.Fatal("bridge TCP endpoint mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeSerialToTCP(t *testing.T) {
|
||||
serial := newTestVirtualSerial([]byte("hello"))
|
||||
|
||||
clientConn, bridgeConn := net.Pipe()
|
||||
|
||||
bridge, err := NewBridge(serial, bridgeConn)
|
||||
if err != nil {
|
||||
t.Fatalf("NewBridge: %v", err)
|
||||
}
|
||||
|
||||
runDone := make(chan error, 1)
|
||||
go func() {
|
||||
runDone <- bridge.Run()
|
||||
}()
|
||||
|
||||
got := make([]byte, 5)
|
||||
if _, err := io.ReadFull(clientConn, got); err != nil {
|
||||
t.Fatalf("read TCP data: %v", err)
|
||||
}
|
||||
|
||||
if string(got) != "hello" {
|
||||
t.Fatalf("received %q, want %q", got, "hello")
|
||||
}
|
||||
|
||||
_ = clientConn.Close()
|
||||
|
||||
select {
|
||||
case err := <-runDone:
|
||||
if err != nil {
|
||||
t.Fatalf("Bridge.Run: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Bridge.Run did not terminate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeTCPToSerial(t *testing.T) {
|
||||
serial := newBlockingVirtualSerial()
|
||||
|
||||
clientConn, bridgeConn := net.Pipe()
|
||||
|
||||
bridge, err := NewBridge(serial, bridgeConn)
|
||||
if err != nil {
|
||||
t.Fatalf("NewBridge: %v", err)
|
||||
}
|
||||
|
||||
runDone := make(chan error, 1)
|
||||
go func() {
|
||||
runDone <- bridge.Run()
|
||||
}()
|
||||
|
||||
want := []byte{0x46, 0x41, 0x00, 0x10, 0x0D}
|
||||
|
||||
if _, err := clientConn.Write(want); err != nil {
|
||||
t.Fatalf("write TCP data: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
if bytes.Equal(serial.Written(), want) {
|
||||
break
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf(
|
||||
"serial received % X, want % X",
|
||||
serial.Written(),
|
||||
want,
|
||||
)
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
|
||||
_ = clientConn.Close()
|
||||
|
||||
select {
|
||||
case err := <-runDone:
|
||||
if err != nil {
|
||||
t.Fatalf("Bridge.Run: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Bridge.Run did not terminate")
|
||||
}
|
||||
}
|
||||
188
internal/client/connection.go
Normal file
188
internal/client/connection.go
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/connection.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* 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"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Virtual port connection
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// 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,
|
||||
virtualPortConfig config.VirtualPortConfig,
|
||||
devices []transport.RemoteDeviceInfo,
|
||||
) (*ManagedVirtualPort, net.Conn, error) {
|
||||
if c == nil || c.conn == nil {
|
||||
return nil, nil, fmt.Errorf("client is not connected")
|
||||
}
|
||||
|
||||
if manager == nil {
|
||||
return nil, nil, fmt.Errorf("virtual port manager is nil")
|
||||
}
|
||||
|
||||
device, err := findRemoteDevice(virtualPortConfig, devices)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
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 %q for %q: %w",
|
||||
virtualPortConfig.Port,
|
||||
virtualPortConfig.RemoteDevice,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
conn, err := c.OpenDataConnection(device)
|
||||
if err != nil {
|
||||
_ = virtualPort.Close()
|
||||
|
||||
return nil, nil, fmt.Errorf(
|
||||
"open data connection for %q: %w",
|
||||
device.ID,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// connectBridge creates the local virtual port, opens the corresponding
|
||||
// remote TCP data connection and creates the data bridge.
|
||||
//
|
||||
// The caller owns the returned bridge and its endpoints.
|
||||
func connectBridge(
|
||||
c *Client,
|
||||
manager *VirtualPortManager,
|
||||
virtualPortConfig config.VirtualPortConfig,
|
||||
devices []transport.RemoteDeviceInfo,
|
||||
) (*Bridge, *ManagedVirtualPort, net.Conn, error) {
|
||||
virtualPort, conn, err := connectVirtualPort(
|
||||
c,
|
||||
manager,
|
||||
virtualPortConfig,
|
||||
devices,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
bridge, err := NewBridge(virtualPort, conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
_ = virtualPort.Close()
|
||||
|
||||
return nil, nil, nil, fmt.Errorf(
|
||||
"create bridge for %q: %w",
|
||||
virtualPortConfig.RemoteDevice,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return bridge, virtualPort, conn, nil
|
||||
}
|
||||
525
internal/client/connection_test.go
Normal file
525
internal/client/connection_test.go
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/connection_test.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Tests für den Aufbau einer Verbindung zwischen einem lokalen virtuellen
|
||||
* seriellen Port und dem zugehörigen entfernten TCP-Gerät.
|
||||
*
|
||||
* Die Tests prüfen insbesondere die Auswahl des Remote-Geräts, die Erzeugung
|
||||
* des virtuellen Ports, den Aufbau der TCP-Datenverbindung und die Verbindung
|
||||
* dieser Endpunkte über eine Bridge.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Test connection
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// testRemoteAddrConn wraps a net.Conn and provides a TCP-style remote address.
|
||||
//
|
||||
// net.Pipe() is used for the control connection in these tests. Its default
|
||||
// RemoteAddr() is "pipe", which cannot be used by OpenDataConnection() to
|
||||
// determine the server host. This wrapper supplies a normal TCP address.
|
||||
type testRemoteAddrConn struct {
|
||||
net.Conn
|
||||
remoteAddr net.Addr
|
||||
}
|
||||
|
||||
// RemoteAddr returns the TCP-style remote address configured for the test.
|
||||
func (c *testRemoteAddrConn) RemoteAddr() net.Addr {
|
||||
return c.remoteAddr
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Test helpers
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// startTestDataServer starts a local TCP listener for a simulated remote
|
||||
// device.
|
||||
//
|
||||
// The listener accepts exactly one connection. The accepted connection is
|
||||
// returned through the channel so the test can verify that the client has
|
||||
// successfully established the data connection.
|
||||
func startTestDataServer(t *testing.T) (string, <-chan net.Conn) {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen for test data server: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
})
|
||||
|
||||
accepted := make(chan net.Conn, 1)
|
||||
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
accepted <- conn
|
||||
}()
|
||||
|
||||
return listener.Addr().String(), accepted
|
||||
}
|
||||
|
||||
// remoteDeviceFromAddress creates a RemoteDeviceInfo whose DataPort is
|
||||
// taken from the supplied local TCP address.
|
||||
func remoteDeviceFromAddress(
|
||||
t *testing.T,
|
||||
id string,
|
||||
address string,
|
||||
) transport.RemoteDeviceInfo {
|
||||
t.Helper()
|
||||
|
||||
_, portText, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
t.Fatalf("split test server address %q: %v", address, err)
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("parse test server port %q: %v", portText, err)
|
||||
}
|
||||
|
||||
return transport.RemoteDeviceInfo{
|
||||
ID: id,
|
||||
Name: id,
|
||||
DataPort: port,
|
||||
}
|
||||
}
|
||||
|
||||
// newTestClient creates a Client whose control connection uses net.Pipe()
|
||||
// but reports a normal TCP-style remote address.
|
||||
func newTestClient(t *testing.T) *Client {
|
||||
t.Helper()
|
||||
|
||||
controlClient, serverConn := net.Pipe()
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = controlClient.Close()
|
||||
_ = serverConn.Close()
|
||||
})
|
||||
|
||||
return &Client{
|
||||
conn: &testRemoteAddrConn{
|
||||
Conn: controlClient,
|
||||
remoteAddr: &net.TCPAddr{
|
||||
IP: net.ParseIP("127.0.0.1"),
|
||||
Port: 5000,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Tests
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func TestConnectVirtualPortNilClient(t *testing.T) {
|
||||
manager := NewVirtualPortManager(
|
||||
config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
},
|
||||
)
|
||||
|
||||
cfg := config.VirtualPortConfig{
|
||||
Port: virtualPortPath(100),
|
||||
RemoteDevice: "radio",
|
||||
}
|
||||
|
||||
devices := []transport.RemoteDeviceInfo{
|
||||
{
|
||||
ID: "radio",
|
||||
DataPort: 50123,
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := connectVirtualPort(
|
||||
nil,
|
||||
manager,
|
||||
cfg,
|
||||
devices,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil client")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "client is not connected") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectVirtualPortNilManager(t *testing.T) {
|
||||
cfg := config.VirtualPortConfig{
|
||||
Port: virtualPortPath(100),
|
||||
RemoteDevice: "radio",
|
||||
}
|
||||
|
||||
devices := []transport.RemoteDeviceInfo{
|
||||
{
|
||||
ID: "radio",
|
||||
DataPort: 50123,
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := connectVirtualPort(
|
||||
&Client{},
|
||||
nil,
|
||||
cfg,
|
||||
devices,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil manager")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "client is not connected") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectVirtualPortUnknownDevice(t *testing.T) {
|
||||
manager := NewVirtualPortManager(
|
||||
config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
},
|
||||
)
|
||||
|
||||
client := newTestClient(t)
|
||||
|
||||
cfg := config.VirtualPortConfig{
|
||||
Port: virtualPortPath(100),
|
||||
RemoteDevice: "unknown",
|
||||
}
|
||||
|
||||
devices := []transport.RemoteDeviceInfo{
|
||||
{
|
||||
ID: "radio",
|
||||
DataPort: 50123,
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := connectVirtualPort(
|
||||
client,
|
||||
manager,
|
||||
cfg,
|
||||
devices,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown device")
|
||||
}
|
||||
|
||||
if !strings.Contains(
|
||||
err.Error(),
|
||||
`remote device "unknown" not found`,
|
||||
) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectVirtualPortClosesPortWhenDataConnectionFails(t *testing.T) {
|
||||
manager := NewVirtualPortManager(
|
||||
config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 100,
|
||||
},
|
||||
)
|
||||
|
||||
client := newTestClient(t)
|
||||
|
||||
cfg := config.VirtualPortConfig{
|
||||
Port: virtualPortPath(100),
|
||||
RemoteDevice: "radio",
|
||||
}
|
||||
|
||||
devices := []transport.RemoteDeviceInfo{
|
||||
{
|
||||
ID: "radio",
|
||||
DataPort: 1,
|
||||
},
|
||||
}
|
||||
|
||||
port, conn, err := connectVirtualPort(
|
||||
client,
|
||||
manager,
|
||||
cfg,
|
||||
devices,
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
if port != nil {
|
||||
_ = port.Close()
|
||||
}
|
||||
|
||||
t.Fatal("expected data connection error")
|
||||
}
|
||||
|
||||
if port != nil {
|
||||
t.Fatal(
|
||||
"virtual port must not be returned after connection failure",
|
||||
)
|
||||
}
|
||||
|
||||
if conn != nil {
|
||||
t.Fatal(
|
||||
"TCP connection must not be returned after connection failure",
|
||||
)
|
||||
}
|
||||
|
||||
// Port 100 must have been released after the failed connection.
|
||||
portNumber, err := manager.Reserve()
|
||||
if err != nil {
|
||||
t.Fatalf("Reserve after cleanup: %v", err)
|
||||
}
|
||||
|
||||
if portNumber != 100 {
|
||||
t.Fatalf("reserved port = %d, want 100", portNumber)
|
||||
}
|
||||
|
||||
manager.Release(portNumber)
|
||||
}
|
||||
|
||||
func TestConnectVirtualPortSuccess(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),
|
||||
)
|
||||
}
|
||||
|
||||
// The test data server must receive the connection created by
|
||||
// OpenDataConnection().
|
||||
select {
|
||||
case conn := <-accepted:
|
||||
_ = conn.Close()
|
||||
|
||||
case <-t.Context().Done():
|
||||
t.Fatal("test context cancelled while waiting for data connection")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
device := remoteDeviceFromAddress(
|
||||
t,
|
||||
"radio",
|
||||
dataAddress,
|
||||
)
|
||||
|
||||
client := newTestClient(t)
|
||||
|
||||
manager := NewVirtualPortManager(
|
||||
config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
},
|
||||
)
|
||||
|
||||
cfg := config.VirtualPortConfig{
|
||||
Port: virtualPortPath(100),
|
||||
RemoteDevice: "radio",
|
||||
}
|
||||
|
||||
bridge, virtualPort, dataConn, err := connectBridge(
|
||||
client,
|
||||
manager,
|
||||
cfg,
|
||||
[]transport.RemoteDeviceInfo{device},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("connectBridge: %v", err)
|
||||
}
|
||||
|
||||
if bridge == nil {
|
||||
t.Fatal("bridge is nil")
|
||||
}
|
||||
|
||||
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 bridge.serial != virtualPort {
|
||||
t.Fatal("bridge serial endpoint does not match virtual port")
|
||||
}
|
||||
|
||||
if bridge.conn != dataConn {
|
||||
t.Fatal("bridge TCP endpoint does not match data connection")
|
||||
}
|
||||
|
||||
if virtualPort.Path() != virtualPortPath(100) {
|
||||
t.Fatalf(
|
||||
"virtual port path = %q, want %q",
|
||||
virtualPort.Path(),
|
||||
virtualPortPath(100),
|
||||
)
|
||||
}
|
||||
|
||||
// The test data server must receive the TCP data connection created by
|
||||
// connectBridge().
|
||||
select {
|
||||
case conn := <-accepted:
|
||||
_ = conn.Close()
|
||||
|
||||
case <-t.Context().Done():
|
||||
t.Fatal("test context cancelled while waiting for data connection")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: control.go
|
||||
* Datei.......: internal/client/control.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||
)
|
||||
|
|
@ -28,6 +29,8 @@ import (
|
|||
|
||||
// Client represents a connection to an rs2322tcp server.
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
}
|
||||
|
|
@ -79,6 +82,9 @@ func (c *Client) Conn() net.Conn {
|
|||
return nil
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
return c.conn
|
||||
}
|
||||
|
||||
|
|
@ -87,12 +93,21 @@ func (c *Client) Conn() net.Conn {
|
|||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (c *Client) hello() error {
|
||||
if c == nil || c.conn == nil {
|
||||
if c == nil {
|
||||
return fmt.Errorf("client is not connected")
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
reader := c.reader
|
||||
c.mu.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
return fmt.Errorf("client is not connected")
|
||||
}
|
||||
|
||||
if err := transport.WriteMessage(
|
||||
c.conn,
|
||||
conn,
|
||||
transport.NewHello(),
|
||||
); err != nil {
|
||||
return fmt.Errorf("send hello: %w", err)
|
||||
|
|
@ -101,7 +116,7 @@ func (c *Client) hello() error {
|
|||
var response transport.HelloResponseMessage
|
||||
|
||||
if err := transport.ReadMessage(
|
||||
c.reader,
|
||||
reader,
|
||||
&response,
|
||||
); err != nil {
|
||||
return fmt.Errorf("read hello response: %w", err)
|
||||
|
|
@ -125,24 +140,39 @@ func (c *Client) hello() error {
|
|||
//
|
||||
// The returned list contains the session-specific dynamic TCP data ports.
|
||||
func (c *Client) GetDevices() ([]transport.RemoteDeviceInfo, error) {
|
||||
if c == nil || c.conn == nil {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("client is not connected")
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
reader := c.reader
|
||||
c.mu.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
return nil, fmt.Errorf("client is not connected")
|
||||
}
|
||||
|
||||
if err := transport.WriteMessage(
|
||||
c.conn,
|
||||
conn,
|
||||
transport.NewGetDevices(),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("send get-devices request: %w", err)
|
||||
return nil, fmt.Errorf(
|
||||
"send get-devices request: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
var response transport.DeviceListMessage
|
||||
|
||||
if err := transport.ReadMessage(
|
||||
c.reader,
|
||||
reader,
|
||||
&response,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("read device list: %w", err)
|
||||
return nil, fmt.Errorf(
|
||||
"read device list: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if response.Type != transport.MessageDeviceList {
|
||||
|
|
@ -160,13 +190,25 @@ func (c *Client) GetDevices() ([]transport.RemoteDeviceInfo, error) {
|
|||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Close closes the client control connection.
|
||||
//
|
||||
// The connection is removed from the Client while holding the mutex.
|
||||
// The actual network close is performed afterwards so that another caller
|
||||
// cannot race with the state change.
|
||||
func (c *Client) Close() error {
|
||||
if c == nil || c.conn == nil {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := c.conn.Close()
|
||||
c.mu.Lock()
|
||||
|
||||
conn := c.conn
|
||||
c.conn = nil
|
||||
|
||||
return err
|
||||
c.mu.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return conn.Close()
|
||||
}
|
||||
|
|
|
|||
70
internal/client/device.go
Normal file
70
internal/client/device.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/device.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Zuordnung eines lokalen virtuellen Ports zu einem vom Server gemeldeten
|
||||
* Remote-Gerät.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Remote device lookup
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// findRemoteDevice finds the server-provided device information matching
|
||||
// the remote device ID configured for a local virtual port.
|
||||
//
|
||||
// The caller uses the returned RemoteDeviceInfo to open the session-specific
|
||||
// TCP data connection.
|
||||
func findRemoteDevice(
|
||||
virtualPort config.VirtualPortConfig,
|
||||
devices []transport.RemoteDeviceInfo,
|
||||
) (transport.RemoteDeviceInfo, error) {
|
||||
if virtualPort.RemoteDevice == "" {
|
||||
return transport.RemoteDeviceInfo{}, fmt.Errorf(
|
||||
"remote device is empty for virtual port %q",
|
||||
virtualPort.Port,
|
||||
)
|
||||
}
|
||||
|
||||
var found *transport.RemoteDeviceInfo
|
||||
|
||||
for i := range devices {
|
||||
device := &devices[i]
|
||||
|
||||
if device.ID != virtualPort.RemoteDevice {
|
||||
continue
|
||||
}
|
||||
|
||||
if found != nil {
|
||||
return transport.RemoteDeviceInfo{}, fmt.Errorf(
|
||||
"remote device %q is ambiguous",
|
||||
virtualPort.RemoteDevice,
|
||||
)
|
||||
}
|
||||
|
||||
found = device
|
||||
}
|
||||
|
||||
if found == nil {
|
||||
return transport.RemoteDeviceInfo{}, fmt.Errorf(
|
||||
"remote device %q not found",
|
||||
virtualPort.RemoteDevice,
|
||||
)
|
||||
}
|
||||
|
||||
return *found, nil
|
||||
}
|
||||
159
internal/client/device_test.go
Normal file
159
internal/client/device_test.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/device_test.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Tests für die Zuordnung lokaler virtueller Ports zu den vom Server
|
||||
* gemeldeten Remote-Geräten.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Test data
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func testRemoteDevices() []transport.RemoteDeviceInfo {
|
||||
return []transport.RemoteDeviceInfo{
|
||||
{
|
||||
ID: "radio",
|
||||
Name: "Radio",
|
||||
BaudRate: 9600,
|
||||
DataBits: 8,
|
||||
Parity: "none",
|
||||
StopBits: 1,
|
||||
DataPort: 50123,
|
||||
},
|
||||
{
|
||||
ID: "rotor",
|
||||
Name: "Rotor",
|
||||
BaudRate: 4800,
|
||||
DataBits: 8,
|
||||
Parity: "none",
|
||||
StopBits: 1,
|
||||
DataPort: 50124,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Tests
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func TestFindRemoteDevice(t *testing.T) {
|
||||
devices := testRemoteDevices()
|
||||
|
||||
virtualPort := config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
RemoteDevice: "radio",
|
||||
}
|
||||
|
||||
got, err := findRemoteDevice(virtualPort, devices)
|
||||
if err != nil {
|
||||
t.Fatalf("findRemoteDevice: %v", err)
|
||||
}
|
||||
|
||||
if got.ID != "radio" {
|
||||
t.Fatalf("ID = %q, want %q", got.ID, "radio")
|
||||
}
|
||||
|
||||
if got.DataPort != 50123 {
|
||||
t.Fatalf("DataPort = %d, want %d", got.DataPort, 50123)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRemoteDeviceRotor(t *testing.T) {
|
||||
devices := testRemoteDevices()
|
||||
|
||||
virtualPort := config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB101",
|
||||
RemoteDevice: "rotor",
|
||||
}
|
||||
|
||||
got, err := findRemoteDevice(virtualPort, devices)
|
||||
if err != nil {
|
||||
t.Fatalf("findRemoteDevice: %v", err)
|
||||
}
|
||||
|
||||
if got.ID != "rotor" {
|
||||
t.Fatalf("ID = %q, want %q", got.ID, "rotor")
|
||||
}
|
||||
|
||||
if got.DataPort != 50124 {
|
||||
t.Fatalf("DataPort = %d, want %d", got.DataPort, 50124)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRemoteDeviceEmptyID(t *testing.T) {
|
||||
devices := testRemoteDevices()
|
||||
|
||||
virtualPort := config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
}
|
||||
|
||||
_, err := findRemoteDevice(virtualPort, devices)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty remote device")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "remote device is empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRemoteDeviceNotFound(t *testing.T) {
|
||||
devices := testRemoteDevices()
|
||||
|
||||
virtualPort := config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
RemoteDevice: "unknown",
|
||||
}
|
||||
|
||||
_, err := findRemoteDevice(virtualPort, devices)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown remote device")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), `remote device "unknown" not found`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRemoteDeviceAmbiguous(t *testing.T) {
|
||||
devices := []transport.RemoteDeviceInfo{
|
||||
{
|
||||
ID: "radio",
|
||||
DataPort: 50123,
|
||||
},
|
||||
{
|
||||
ID: "radio",
|
||||
DataPort: 50125,
|
||||
},
|
||||
}
|
||||
|
||||
virtualPort := config.VirtualPortConfig{
|
||||
Port: "/dev/ttyUSB100",
|
||||
RemoteDevice: "radio",
|
||||
}
|
||||
|
||||
_, err := findRemoteDevice(virtualPort, devices)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for ambiguous remote device")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), `remote device "radio" is ambiguous`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,19 +1,51 @@
|
|||
//go:build linux
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/pty_linux.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Linux-spezifische Implementierung der virtuellen seriellen Schnittstelle
|
||||
* über ein PTY-Paar.
|
||||
*
|
||||
* Der PTY-Master wird intern vom Client verwendet. Der zugehörige PTY-Slave
|
||||
* wird über den virtuellen Port als serielle Schnittstelle bereitgestellt.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// ptySerial
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// ptySerial implements VirtualSerial using a Linux PTY pair.
|
||||
//
|
||||
// The file descriptors are stored atomically because Read/Write may execute
|
||||
// concurrently with Close. Close must be able to invalidate the descriptors
|
||||
// without racing with a concurrent Read or Write.
|
||||
type ptySerial struct {
|
||||
masterFD int
|
||||
slaveFD int
|
||||
masterFD atomic.Int64
|
||||
slaveFD atomic.Int64
|
||||
path string
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Constructor
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// newVirtualSerial creates a new Linux PTY pair.
|
||||
func newVirtualSerial() (VirtualSerial, error) {
|
||||
masterFD, err := unix.Open(
|
||||
"/dev/ptmx",
|
||||
|
|
@ -60,7 +92,11 @@ func newVirtualSerial() (VirtualSerial, error) {
|
|||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open PTY slave %s: %w", path, err)
|
||||
return nil, fmt.Errorf(
|
||||
"open PTY slave %s: %w",
|
||||
path,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
closeSlave := true
|
||||
|
|
@ -74,16 +110,23 @@ func newVirtualSerial() (VirtualSerial, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
serial := &ptySerial{
|
||||
path: path,
|
||||
}
|
||||
|
||||
serial.masterFD.Store(int64(masterFD))
|
||||
serial.slaveFD.Store(int64(slaveFD))
|
||||
|
||||
closeMaster = false
|
||||
closeSlave = false
|
||||
|
||||
return &ptySerial{
|
||||
masterFD: masterFD,
|
||||
slaveFD: slaveFD,
|
||||
path: path,
|
||||
}, nil
|
||||
return serial, nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// PTY configuration
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func configurePTY(fd int) error {
|
||||
termios, err := unix.IoctlGetTermios(
|
||||
fd,
|
||||
|
|
@ -134,33 +177,80 @@ func configurePTY(fd int) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// VirtualSerial implementation
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Path returns the PTY slave path.
|
||||
func (p *ptySerial) Path() string {
|
||||
return p.path
|
||||
}
|
||||
|
||||
// Read reads raw bytes from the PTY master.
|
||||
func (p *ptySerial) Read(b []byte) (int, error) {
|
||||
return unix.Read(p.masterFD, b)
|
||||
fd := int(p.masterFD.Load())
|
||||
|
||||
if fd < 0 {
|
||||
return 0, fmt.Errorf("PTY master is closed")
|
||||
}
|
||||
|
||||
return unix.Read(fd, b)
|
||||
}
|
||||
|
||||
// Write writes raw bytes to the PTY master.
|
||||
func (p *ptySerial) Write(b []byte) (int, error) {
|
||||
return unix.Write(p.masterFD, b)
|
||||
fd := int(p.masterFD.Load())
|
||||
|
||||
if fd < 0 {
|
||||
return 0, fmt.Errorf("PTY master is closed")
|
||||
}
|
||||
|
||||
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.
|
||||
//
|
||||
// The descriptors are invalidated atomically before closing them so that
|
||||
// concurrent Read or Write calls do not access the descriptor fields
|
||||
// concurrently with Close.
|
||||
func (p *ptySerial) Close() error {
|
||||
var firstErr error
|
||||
|
||||
if p.masterFD >= 0 {
|
||||
if err := unix.Close(p.masterFD); err != nil {
|
||||
masterFD := int(p.masterFD.Swap(-1))
|
||||
if masterFD >= 0 {
|
||||
if err := unix.Close(masterFD); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
p.masterFD = -1
|
||||
}
|
||||
|
||||
if p.slaveFD >= 0 {
|
||||
if err := unix.Close(p.slaveFD); err != nil && firstErr == nil {
|
||||
slaveFD := int(p.slaveFD.Swap(-1))
|
||||
if slaveFD >= 0 {
|
||||
if err := unix.Close(slaveFD); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
p.slaveFD = -1
|
||||
}
|
||||
|
||||
return firstErr
|
||||
|
|
|
|||
220
internal/client/runtime.go
Normal file
220
internal/client/runtime.go
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/runtime.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Clientseitige Laufzeitverwaltung für die konfigurierten virtuellen
|
||||
* seriellen Schnittstellen.
|
||||
*
|
||||
* Die Runtime verbindet die in der Client-Konfiguration angegebenen
|
||||
* virtuellen Ports mit den zugehörigen entfernten Geräten.
|
||||
*
|
||||
* Für jedes konfigurierte virtuelle Gerät wird eine eigene Bridge gestartet:
|
||||
*
|
||||
* /dev/ttyUSBxxx ─── Bridge ─── TCP Data Connection
|
||||
*
|
||||
* Die Control-Verbindung zum Server wird gemeinsam von allen Bridges
|
||||
* verwendet.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Runtime
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Runtime manages the client-side virtual serial connections.
|
||||
//
|
||||
// A Runtime owns the Client control connection and the VirtualPortManager.
|
||||
// Each configured virtual port is represented by one running Bridge.
|
||||
type Runtime struct {
|
||||
client *Client
|
||||
manager *VirtualPortManager
|
||||
|
||||
mu sync.Mutex
|
||||
bridges []*Bridge
|
||||
}
|
||||
|
||||
// NewRuntime creates a new client Runtime.
|
||||
//
|
||||
// The Runtime does not establish a server connection and does not open any
|
||||
// virtual ports until Run is called.
|
||||
func NewRuntime(
|
||||
client *Client,
|
||||
manager *VirtualPortManager,
|
||||
) (*Runtime, error) {
|
||||
if client == nil {
|
||||
return nil, fmt.Errorf("client is nil")
|
||||
}
|
||||
|
||||
if manager == nil {
|
||||
return nil, fmt.Errorf("virtual port manager is nil")
|
||||
}
|
||||
|
||||
return &Runtime{
|
||||
client: client,
|
||||
manager: manager,
|
||||
}, nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Run
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Run starts all configured virtual serial connections.
|
||||
//
|
||||
// The supplied configuration determines which local virtual ports are
|
||||
// assigned to which remote devices.
|
||||
//
|
||||
// Run blocks until one bridge terminates or an error occurs while creating
|
||||
// one of the configured bridges.
|
||||
func (r *Runtime) Run(
|
||||
cfg config.ClientConfig,
|
||||
) error {
|
||||
if r == nil || r.client == nil {
|
||||
return fmt.Errorf("runtime is not initialized")
|
||||
}
|
||||
|
||||
if r.manager == nil {
|
||||
return fmt.Errorf("virtual port manager is nil")
|
||||
}
|
||||
|
||||
if len(cfg.VirtualPorts) == 0 {
|
||||
return nil
|
||||
}
|
||||
devices, err := r.client.GetDevices()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get remote devices: %w", err)
|
||||
}
|
||||
|
||||
for _, virtualPortConfig := range cfg.VirtualPorts {
|
||||
if virtualPortConfig.RemoteDevice == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
bridge, _, _, err := connectBridge(
|
||||
r.client,
|
||||
r.manager,
|
||||
virtualPortConfig,
|
||||
devices,
|
||||
)
|
||||
if err != nil {
|
||||
r.Close()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.bridges = append(r.bridges, bridge)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
return r.waitForBridge()
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Bridge lifecycle
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// waitForBridge waits until one of the running bridges terminates.
|
||||
//
|
||||
// A bridge terminating is considered the end of the Runtime. The Bridge
|
||||
// itself closes its serial and TCP endpoints when Run returns.
|
||||
func (r *Runtime) waitForBridge() error {
|
||||
results := make(chan error, len(r.bridges))
|
||||
|
||||
r.mu.Lock()
|
||||
bridges := append([]*Bridge(nil), r.bridges...)
|
||||
r.mu.Unlock()
|
||||
|
||||
for _, bridge := range bridges {
|
||||
go func(b *Bridge) {
|
||||
results <- b.Run()
|
||||
}(bridge)
|
||||
}
|
||||
|
||||
err := <-results
|
||||
|
||||
r.Close()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Close
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Close closes all resources owned by the Runtime.
|
||||
//
|
||||
// Closing the bridge endpoints causes running Bridge.Run calls to terminate.
|
||||
// The client control connection is closed afterwards.
|
||||
//
|
||||
// Calling Close more than once is safe.
|
||||
|
||||
func (r *Runtime) Close() error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
bridges := append([]*Bridge(nil), r.bridges...)
|
||||
r.bridges = nil
|
||||
r.mu.Unlock()
|
||||
|
||||
var firstErr error
|
||||
|
||||
for _, bridge := range bridges {
|
||||
if bridge == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if bridge.serial != nil {
|
||||
if err := bridge.serial.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
|
||||
if bridge.conn != nil {
|
||||
if err := bridge.conn.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if r.client != nil {
|
||||
if err := r.client.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// Client returns the Client used by the Runtime.
|
||||
func (r *Runtime) Client() *Client {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.client
|
||||
}
|
||||
|
||||
// Manager returns the VirtualPortManager used by the Runtime.
|
||||
func (r *Runtime) Manager() *VirtualPortManager {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.manager
|
||||
}
|
||||
281
internal/client/runtime_integration_test.go
Normal file
281
internal/client/runtime_integration_test.go
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/runtime_integration_test.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Integrationstest für die clientseitige Runtime mit einem echten
|
||||
* rs2322tcp-Server und einem virtuellen seriellen Client-Port.
|
||||
*
|
||||
* Der Test verwendet die vorhandene virtuelle serielle Test-Schnittstelle
|
||||
* aus integration_test.go und prüft den vollständigen Datenweg:
|
||||
*
|
||||
* /dev/ttyUSB100
|
||||
* ↕
|
||||
* Bridge
|
||||
* ↕
|
||||
* TCP Data Connection
|
||||
* ↕
|
||||
* rs2322tcp Server
|
||||
* ↕
|
||||
* serielle PTY
|
||||
*
|
||||
* Beide Übertragungsrichtungen werden geprüft.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/client"
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
"git.lang-dieter.de/rs2322tcp/internal/server"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Tests
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func TestClientRuntimeSerialIntegration(t *testing.T) {
|
||||
serialA, serialB, cleanup := startVirtualSerialPair(t)
|
||||
defer cleanup()
|
||||
|
||||
const virtualPortPath = "/dev/ttyUSB100"
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatalf("get user home directory: %v", err)
|
||||
}
|
||||
|
||||
internalPortPath := filepath.Join(
|
||||
home,
|
||||
".rs2322tcp",
|
||||
"virtual",
|
||||
"ttyUSB100",
|
||||
)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Server
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
controlProbe, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("find free control port: %v", err)
|
||||
}
|
||||
|
||||
controlPort := controlProbe.Addr().(*net.TCPAddr).Port
|
||||
|
||||
if err := controlProbe.Close(); err != nil {
|
||||
t.Fatalf("close control probe: %v", err)
|
||||
}
|
||||
|
||||
serverConfig := &config.ServerConfig{
|
||||
Listen: config.ListenConfig{
|
||||
Address: "127.0.0.1",
|
||||
Port: controlPort,
|
||||
},
|
||||
HardwareErrorResponse: testHardwareErrorResponse,
|
||||
Devices: []config.DeviceConfig{
|
||||
{
|
||||
ID: "radio",
|
||||
Name: "Funkgerät",
|
||||
SerialPort: serialA,
|
||||
BaudRate: 9600,
|
||||
DataBits: 8,
|
||||
Parity: "none",
|
||||
StopBits: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
srv, err := server.NewControlServer(serverConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("NewControlServer() failed: %v", err)
|
||||
}
|
||||
|
||||
if err := srv.Listen(); err != nil {
|
||||
t.Fatalf("server Listen() failed: %v", err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
go func() {
|
||||
_ = srv.Serve()
|
||||
}()
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Client and Runtime
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
c, err := client.New(srv.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("client.New() failed: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
manager := client.NewVirtualPortManager(
|
||||
config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
},
|
||||
)
|
||||
|
||||
runtime, err := client.NewRuntime(c, manager)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime() failed: %v", err)
|
||||
}
|
||||
defer runtime.Close()
|
||||
|
||||
clientConfig := config.ClientConfig{
|
||||
Server: config.ServerConnectionConfig{
|
||||
Address: "127.0.0.1",
|
||||
Port: controlPort,
|
||||
},
|
||||
VirtualPorts: []config.VirtualPortConfig{
|
||||
{
|
||||
Port: virtualPortPath,
|
||||
RemoteDevice: "radio",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Start Runtime
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
runtimeDone := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
runtimeDone <- runtime.Run(clientConfig)
|
||||
}()
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Open client-side virtual serial endpoint
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
var virtualSerial *os.File
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
|
||||
for {
|
||||
virtualSerial, err = os.OpenFile(
|
||||
internalPortPath,
|
||||
os.O_RDWR,
|
||||
0,
|
||||
)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf(
|
||||
"timeout opening virtual serial port %s: %v",
|
||||
internalPortPath,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
defer virtualSerial.Close()
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Client virtual serial -> Bridge -> Server -> serial
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
clientMessage := []byte("hello from runtime")
|
||||
|
||||
if _, err := virtualSerial.Write(clientMessage); err != nil {
|
||||
t.Fatalf(
|
||||
"write to virtual serial port failed: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
serialReceived := make([]byte, len(clientMessage))
|
||||
|
||||
serialPeer, err := os.OpenFile(
|
||||
serialB,
|
||||
os.O_RDWR,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"open server serial peer: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
defer serialPeer.Close()
|
||||
|
||||
readExactWithTimeout(
|
||||
t,
|
||||
serialPeer,
|
||||
serialReceived,
|
||||
2*time.Second,
|
||||
)
|
||||
|
||||
if string(serialReceived) != string(clientMessage) {
|
||||
t.Fatalf(
|
||||
"serial received %q, want %q",
|
||||
string(serialReceived),
|
||||
string(clientMessage),
|
||||
)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Server serial -> Bridge -> Client virtual serial
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
serverMessage := []byte("hello from server")
|
||||
|
||||
if _, err := serialPeer.Write(serverMessage); err != nil {
|
||||
t.Fatalf(
|
||||
"write to server serial peer failed: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
clientReceived := make([]byte, len(serverMessage))
|
||||
|
||||
readExactWithTimeout(
|
||||
t,
|
||||
virtualSerial,
|
||||
clientReceived,
|
||||
2*time.Second,
|
||||
)
|
||||
|
||||
if string(clientReceived) != string(serverMessage) {
|
||||
t.Fatalf(
|
||||
"virtual serial received %q, want %q",
|
||||
string(clientReceived),
|
||||
string(serverMessage),
|
||||
)
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Terminate Runtime
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
if err := virtualSerial.Close(); err != nil {
|
||||
t.Fatalf("close virtual serial port: %v", err)
|
||||
}
|
||||
|
||||
if err := runtime.Close(); err != nil {
|
||||
t.Fatalf("close runtime: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-runtimeDone:
|
||||
// Runtime termination is expected after Runtime.Close().
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("runtime did not terminate after Runtime.Close()")
|
||||
}
|
||||
}
|
||||
82
internal/client/runtime_test.go
Normal file
82
internal/client/runtime_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/runtime_test.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Tests für die clientseitige Runtime.
|
||||
*
|
||||
* Die Tests prüfen die Initialisierung der Runtime und den grundlegenden
|
||||
* Lebenszyklus ohne einen externen rs2322tcp-Server vorauszusetzen.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Tests
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func TestNewRuntimeNilClient(t *testing.T) {
|
||||
manager := NewVirtualPortManager(
|
||||
config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
},
|
||||
)
|
||||
|
||||
_, err := NewRuntime(nil, manager)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil client")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "client is nil") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRuntimeNilManager(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
_, err := NewRuntime(client, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil manager")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "virtual port manager is nil") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunWithoutVirtualPorts(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
manager := NewVirtualPortManager(
|
||||
config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
},
|
||||
)
|
||||
|
||||
runtime, err := NewRuntime(client, manager)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.ClientConfig{
|
||||
VirtualPorts: nil,
|
||||
}
|
||||
|
||||
if err := runtime.Run(cfg); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
}
|
||||
70
internal/client/virtual_port.go
Normal file
70
internal/client/virtual_port.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
Package client contains the client-side components of rs2322tcp.
|
||||
|
||||
Project: rs2322tcp
|
||||
Module: git.lang-dieter.de/rs2322tcp
|
||||
*/
|
||||
package client
|
||||
|
||||
import "io"
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Virtual port
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// VirtualPort represents one local virtual serial port.
|
||||
//
|
||||
// The visible path is the device name presented to the external application,
|
||||
// for example /dev/ttyUSB100.
|
||||
//
|
||||
// The underlying VirtualSerial remains an internal implementation detail and
|
||||
// may use a PTY such as /dev/pts/2.
|
||||
type VirtualPort struct {
|
||||
path string
|
||||
serial VirtualSerial
|
||||
}
|
||||
|
||||
// NewVirtualPort creates a virtual port with the specified visible path and
|
||||
// underlying virtual serial device.
|
||||
func NewVirtualPort(path string, serial VirtualSerial) *VirtualPort {
|
||||
return &VirtualPort{
|
||||
path: path,
|
||||
serial: serial,
|
||||
}
|
||||
}
|
||||
|
||||
// Path returns the device path visible to the external application.
|
||||
func (p *VirtualPort) Path() string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return p.path
|
||||
}
|
||||
|
||||
// Read reads data from the underlying virtual serial device.
|
||||
func (p *VirtualPort) Read(b []byte) (int, error) {
|
||||
if p == nil || p.serial == nil {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
return p.serial.Read(b)
|
||||
}
|
||||
|
||||
// Write writes data to the underlying virtual serial device.
|
||||
func (p *VirtualPort) Write(b []byte) (int, error) {
|
||||
if p == nil || p.serial == nil {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
return p.serial.Write(b)
|
||||
}
|
||||
|
||||
// Close closes the underlying virtual serial device.
|
||||
func (p *VirtualPort) Close() error {
|
||||
if p == nil || p.serial == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.serial.Close()
|
||||
}
|
||||
149
internal/client/virtual_port_linux.go
Normal file
149
internal/client/virtual_port_linux.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
//go:build linux
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_linux.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Linux-spezifische Verwaltung der internen Symlinks für virtuelle
|
||||
* rs2322tcp-Seriellschnittstellen.
|
||||
*
|
||||
* Der öffentliche Gerätename /dev/ttyUSBxxx wird bei der Installation
|
||||
* vorbereitet. Der Client verwaltet ausschließlich den benutzerbezogenen
|
||||
* Link unter ~/.rs2322tcp/virtual/.
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
virtualDirectoryName = ".rs2322tcp/virtual"
|
||||
)
|
||||
|
||||
// virtualPortDirectory returns the directory in the current user's home
|
||||
// directory used for the internal virtual-port links.
|
||||
func virtualPortDirectory() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get user home directory: %w", err)
|
||||
}
|
||||
|
||||
return filepath.Join(home, virtualDirectoryName), nil
|
||||
}
|
||||
|
||||
// virtualPortLinkPath returns the path of the internal link for a virtual
|
||||
// serial port.
|
||||
func virtualPortLinkPath(portPath string) (string, error) {
|
||||
directory, err := virtualPortDirectory()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(directory, filepath.Base(portPath)), nil
|
||||
}
|
||||
|
||||
// ensureVirtualPortDirectory creates the internal virtual-port directory
|
||||
// if it does not already exist.
|
||||
func ensureVirtualPortDirectory() error {
|
||||
directory, err := virtualPortDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(directory, 0755); err != nil {
|
||||
return fmt.Errorf(
|
||||
"create virtual port directory %q: %w",
|
||||
directory,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setVirtualPortLink creates or replaces the internal link for a virtual
|
||||
// port.
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
|
||||
if target == "" {
|
||||
return fmt.Errorf("virtual port target is empty")
|
||||
}
|
||||
|
||||
if err := ensureVirtualPortDirectory(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
linkPath, err := virtualPortLinkPath(portPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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",
|
||||
linkPath,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := os.Symlink(target, linkPath); err != nil {
|
||||
return fmt.Errorf(
|
||||
"create virtual port link %q -> %q: %w",
|
||||
linkPath,
|
||||
target,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeVirtualPortLink removes the user-owned internal link for a virtual
|
||||
// port.
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
|
||||
linkPath, err := virtualPortLinkPath(portPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Remove(linkPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf(
|
||||
"remove existing virtual port link %q: %w",
|
||||
linkPath,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
348
internal/client/virtual_port_linux_test.go
Normal file
348
internal/client/virtual_port_linux_test.go
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
//go:build linux
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_linux_test.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Tests für die Linux-spezifische Verwaltung der internen Symlinks
|
||||
* virtueller rs2322tcp-Seriellschnittstellen.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
)
|
||||
|
||||
func TestVirtualPortLinkPath(t *testing.T) {
|
||||
path, err := virtualPortLinkPath("/dev/ttyUSB100")
|
||||
if err != nil {
|
||||
t.Fatalf("virtualPortLinkPath() failed: %v", err)
|
||||
}
|
||||
|
||||
wantSuffix := filepath.Join(
|
||||
".rs2322tcp",
|
||||
"virtual",
|
||||
"ttyUSB100",
|
||||
)
|
||||
|
||||
if !filepath.IsAbs(path) {
|
||||
t.Fatalf("path is not absolute: %q", path)
|
||||
}
|
||||
|
||||
if !hasPathSuffix(path, wantSuffix) {
|
||||
t.Errorf(
|
||||
"virtualPortLinkPath() = %q, want suffix %q",
|
||||
path,
|
||||
wantSuffix,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAndRemoveVirtualPortLink(t *testing.T) {
|
||||
tempHome := t.TempDir()
|
||||
|
||||
t.Setenv("HOME", tempHome)
|
||||
|
||||
portPath := "/dev/ttyUSB100"
|
||||
target := "/dev/pts/2"
|
||||
|
||||
if err := setVirtualPortLink(portPath, target); err != nil {
|
||||
t.Fatalf("setVirtualPortLink() failed: %v", err)
|
||||
}
|
||||
|
||||
linkPath, err := virtualPortLinkPath(portPath)
|
||||
if err != nil {
|
||||
t.Fatalf("virtualPortLinkPath() failed: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.Readlink(linkPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Readlink() failed: %v", err)
|
||||
}
|
||||
|
||||
if got != target {
|
||||
t.Errorf(
|
||||
"link target = %q, want %q",
|
||||
got,
|
||||
target,
|
||||
)
|
||||
}
|
||||
|
||||
if err := removeVirtualPortLink(portPath); err != nil {
|
||||
t.Fatalf("removeVirtualPortLink() failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Lstat(linkPath); !os.IsNotExist(err) {
|
||||
t.Fatalf(
|
||||
"link still exists after removal: %q",
|
||||
linkPath,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVirtualPortLinkReplacesExistingLink(t *testing.T) {
|
||||
tempHome := t.TempDir()
|
||||
|
||||
t.Setenv("HOME", tempHome)
|
||||
|
||||
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/7",
|
||||
); err != nil {
|
||||
t.Fatalf(
|
||||
"second setVirtualPortLink() failed: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
linkPath, err := virtualPortLinkPath(portPath)
|
||||
if err != nil {
|
||||
t.Fatalf("virtualPortLinkPath() failed: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.Readlink(linkPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Readlink() failed: %v", err)
|
||||
}
|
||||
|
||||
if got != "/dev/pts/7" {
|
||||
t.Errorf(
|
||||
"link target = %q, want %q",
|
||||
got,
|
||||
"/dev/pts/7",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveVirtualPortLinkMissing(t *testing.T) {
|
||||
tempHome := t.TempDir()
|
||||
|
||||
t.Setenv("HOME", tempHome)
|
||||
|
||||
if err := removeVirtualPortLink("/dev/ttyUSB100"); err != nil {
|
||||
t.Fatalf(
|
||||
"removeVirtualPortLink() failed for missing link: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortLinkPathUsesBaseName(t *testing.T) {
|
||||
path, err := virtualPortLinkPath("/dev/ttyUSB123")
|
||||
if err != nil {
|
||||
t.Fatalf("virtualPortLinkPath() failed: %v", err)
|
||||
}
|
||||
|
||||
if filepath.Base(path) != "ttyUSB123" {
|
||||
t.Errorf(
|
||||
"filepath.Base() = %q, want %q",
|
||||
filepath.Base(path),
|
||||
"ttyUSB123",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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) &&
|
||||
path[len(path)-len(suffix):] == suffix
|
||||
}
|
||||
183
internal/client/virtual_port_manager.go
Normal file
183
internal/client/virtual_port_manager.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_manager.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* 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 (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// VirtualPortManager
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// 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
|
||||
|
||||
first int
|
||||
last int
|
||||
used map[int]bool
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Constructor
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// NewVirtualPortManager erzeugt einen VirtualPortManager aus dem
|
||||
// konfigurierten Portbereich.
|
||||
func NewVirtualPortManager(
|
||||
cfg config.VirtualPortRangeConfig,
|
||||
) *VirtualPortManager {
|
||||
return &VirtualPortManager{
|
||||
first: cfg.First,
|
||||
last: cfg.Last,
|
||||
used: make(map[int]bool),
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// 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 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")
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for number := m.first; number <= m.last; number++ {
|
||||
if m.used[number] {
|
||||
continue
|
||||
}
|
||||
|
||||
m.used[number] = true
|
||||
|
||||
return number, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf(
|
||||
"no free virtual port in range %d-%d",
|
||||
m.first,
|
||||
m.last,
|
||||
)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
delete(m.used, number)
|
||||
}
|
||||
80
internal/client/virtual_port_manager_linux.go
Normal file
80
internal/client/virtual_port_manager_linux.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//go:build linux
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_manager_linux.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Linux-spezifische Erzeugung eines virtuellen rs2322tcp-Ports.
|
||||
*
|
||||
* 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"
|
||||
)
|
||||
|
||||
// openReserved creates the actual Linux virtual serial port for an already
|
||||
// reserved port number.
|
||||
//
|
||||
// The caller must reserve the port before calling this method.
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
|
||||
portPath := m.PortPath(number)
|
||||
|
||||
serial, err := newVirtualSerial()
|
||||
if err != nil {
|
||||
m.Release(number)
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"create virtual serial for %s: %w",
|
||||
portPath,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := setVirtualPortLink(
|
||||
portPath,
|
||||
serial.Path(),
|
||||
); err != nil {
|
||||
_ = serial.Close()
|
||||
m.Release(number)
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"bind %s to %s: %w",
|
||||
portPath,
|
||||
serial.Path(),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
port := NewVirtualPort(portPath, serial)
|
||||
|
||||
return &ManagedVirtualPort{
|
||||
port: port,
|
||||
manager: m,
|
||||
number: number,
|
||||
}, nil
|
||||
}
|
||||
296
internal/client/virtual_port_manager_linux_test.go
Normal file
296
internal/client/virtual_port_manager_linux_test.go
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
//go:build linux
|
||||
|
||||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_manager_linux_test.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Linux-spezifische Tests für die Erzeugung und Verwaltung virtueller
|
||||
* rs2322tcp-Ports.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Tests
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func TestVirtualPortManagerOpen(t *testing.T) {
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("HOME", tempHome)
|
||||
|
||||
manager := NewVirtualPortManager(
|
||||
config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
},
|
||||
)
|
||||
|
||||
port, err := manager.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("Open() failed: %v", err)
|
||||
}
|
||||
|
||||
if port == nil {
|
||||
t.Fatal("Open() returned nil")
|
||||
}
|
||||
|
||||
if port.Path() != "/dev/ttyUSB100" {
|
||||
t.Errorf(
|
||||
"Path() = %q, want %q",
|
||||
port.Path(),
|
||||
"/dev/ttyUSB100",
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerOpenUsesNextPort(t *testing.T) {
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("HOME", tempHome)
|
||||
|
||||
manager := NewVirtualPortManager(
|
||||
config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 101,
|
||||
},
|
||||
)
|
||||
|
||||
first, err := manager.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("first Open() failed: %v", err)
|
||||
}
|
||||
|
||||
second, err := manager.Open()
|
||||
if err != nil {
|
||||
_ = first.Close()
|
||||
t.Fatalf("second Open() failed: %v", err)
|
||||
}
|
||||
|
||||
if first.Path() != "/dev/ttyUSB100" {
|
||||
t.Errorf(
|
||||
"first.Path() = %q, want %q",
|
||||
first.Path(),
|
||||
"/dev/ttyUSB100",
|
||||
)
|
||||
}
|
||||
|
||||
if second.Path() != "/dev/ttyUSB101" {
|
||||
t.Errorf(
|
||||
"second.Path() = %q, want %q",
|
||||
second.Path(),
|
||||
"/dev/ttyUSB101",
|
||||
)
|
||||
}
|
||||
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("first Close() failed: %v", err)
|
||||
}
|
||||
|
||||
if err := second.Close(); err != nil {
|
||||
t.Fatalf("second Close() failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerOpenReusesReleasedPort(t *testing.T) {
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("HOME", tempHome)
|
||||
|
||||
manager := NewVirtualPortManager(
|
||||
config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 100,
|
||||
},
|
||||
)
|
||||
|
||||
first, err := manager.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("first Open() failed: %v", err)
|
||||
}
|
||||
|
||||
if first.Path() != "/dev/ttyUSB100" {
|
||||
t.Fatalf(
|
||||
"first.Path() = %q, want %q",
|
||||
first.Path(),
|
||||
"/dev/ttyUSB100",
|
||||
)
|
||||
}
|
||||
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("first Close() failed: %v", err)
|
||||
}
|
||||
|
||||
second, err := manager.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("second Open() failed: %v", err)
|
||||
}
|
||||
|
||||
if second.Path() != "/dev/ttyUSB100" {
|
||||
t.Errorf(
|
||||
"second.Path() = %q, want %q",
|
||||
second.Path(),
|
||||
"/dev/ttyUSB100",
|
||||
)
|
||||
}
|
||||
|
||||
if err := second.Close(); err != nil {
|
||||
t.Fatalf("second Close() failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerOpenNoFreePort(t *testing.T) {
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("HOME", tempHome)
|
||||
|
||||
manager := NewVirtualPortManager(
|
||||
config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 100,
|
||||
},
|
||||
)
|
||||
|
||||
port, err := manager.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("first Open() failed: %v", err)
|
||||
}
|
||||
|
||||
defer port.Close()
|
||||
|
||||
if _, err := manager.Open(); err == nil {
|
||||
t.Fatal("second Open() succeeded, want no-free-port error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedVirtualPortDataPath(t *testing.T) {
|
||||
tempHome := t.TempDir()
|
||||
t.Setenv("HOME", tempHome)
|
||||
|
||||
manager := NewVirtualPortManager(
|
||||
config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 100,
|
||||
},
|
||||
)
|
||||
|
||||
port, err := manager.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("Open() failed: %v", err)
|
||||
}
|
||||
|
||||
defer port.Close()
|
||||
|
||||
data := []byte{0x46, 0x41, 0x00, 0x10, 0x0D}
|
||||
|
||||
if _, err := port.Write(data); err != nil {
|
||||
t.Fatalf("Write() failed: %v", err)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
304
internal/client/virtual_port_manager_test.go
Normal file
304
internal/client/virtual_port_manager_test.go
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_manager_test.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* 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
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||
)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Tests
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func TestNewVirtualPortManager(t *testing.T) {
|
||||
cfg := config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
}
|
||||
|
||||
manager := NewVirtualPortManager(cfg)
|
||||
|
||||
if manager == nil {
|
||||
t.Fatal("NewVirtualPortManager() returned nil")
|
||||
}
|
||||
|
||||
if manager.first != 100 {
|
||||
t.Errorf("first = %d, want 100", manager.first)
|
||||
}
|
||||
|
||||
if manager.last != 199 {
|
||||
t.Errorf("last = %d, want 199", manager.last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerReserveFirst(t *testing.T) {
|
||||
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
})
|
||||
|
||||
number, err := manager.Reserve()
|
||||
if err != nil {
|
||||
t.Fatalf("Reserve() failed: %v", err)
|
||||
}
|
||||
|
||||
if number != 100 {
|
||||
t.Errorf("Reserve() = %d, want 100", number)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerReserveNextFree(t *testing.T) {
|
||||
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
})
|
||||
|
||||
first, err := manager.Reserve()
|
||||
if err != nil {
|
||||
t.Fatalf("first Reserve() failed: %v", err)
|
||||
}
|
||||
|
||||
second, err := manager.Reserve()
|
||||
if err != nil {
|
||||
t.Fatalf("second Reserve() failed: %v", err)
|
||||
}
|
||||
|
||||
if first != 100 {
|
||||
t.Errorf("first Reserve() = %d, want 100", first)
|
||||
}
|
||||
|
||||
if second != 101 {
|
||||
t.Errorf("second Reserve() = %d, want 101", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerRelease(t *testing.T) {
|
||||
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
})
|
||||
|
||||
number, err := manager.Reserve()
|
||||
if err != nil {
|
||||
t.Fatalf("Reserve() failed: %v", err)
|
||||
}
|
||||
|
||||
manager.Release(number)
|
||||
|
||||
reused, err := manager.Reserve()
|
||||
if err != nil {
|
||||
t.Fatalf("Reserve() after Release() failed: %v", err)
|
||||
}
|
||||
|
||||
if reused != 100 {
|
||||
t.Errorf(
|
||||
"Reserve() after Release() = %d, want 100",
|
||||
reused,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerSkipsReservedPorts(t *testing.T) {
|
||||
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := manager.Reserve(); err != nil {
|
||||
t.Fatalf("Reserve() %d failed: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
number, err := manager.Reserve()
|
||||
if err != nil {
|
||||
t.Fatalf("fourth Reserve() failed: %v", err)
|
||||
}
|
||||
|
||||
if number != 103 {
|
||||
t.Errorf("fourth Reserve() = %d, want 103", number)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerCustomRange(t *testing.T) {
|
||||
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
|
||||
First: 200,
|
||||
Last: 299,
|
||||
})
|
||||
|
||||
number, err := manager.Reserve()
|
||||
if err != nil {
|
||||
t.Fatalf("Reserve() failed: %v", err)
|
||||
}
|
||||
|
||||
if number != 200 {
|
||||
t.Errorf("Reserve() = %d, want 200", number)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerNoFreePort(t *testing.T) {
|
||||
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 102,
|
||||
})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := manager.Reserve(); err != nil {
|
||||
t.Fatalf("Reserve() %d failed: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := manager.Reserve(); err == nil {
|
||||
t.Fatal("Reserve() succeeded, want no-free-port error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortManagerPortPath(t *testing.T) {
|
||||
manager := NewVirtualPortManager(config.VirtualPortRangeConfig{
|
||||
First: 100,
|
||||
Last: 199,
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
number int
|
||||
want string
|
||||
}{
|
||||
{
|
||||
number: 100,
|
||||
want: virtualPortPath(100),
|
||||
},
|
||||
{
|
||||
number: 123,
|
||||
want: virtualPortPath(123),
|
||||
},
|
||||
{
|
||||
number: 199,
|
||||
want: virtualPortPath(199),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
got := manager.PortPath(test.number)
|
||||
|
||||
if got != test.want {
|
||||
t.Errorf(
|
||||
"PortPath(%d) = %q, want %q",
|
||||
test.number,
|
||||
got,
|
||||
test.want,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if path := manager.PortPath(100); path != "" {
|
||||
t.Errorf("nil PortPath() = %q, want empty string", path)
|
||||
}
|
||||
|
||||
if _, err := manager.Reserve(); err == nil {
|
||||
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)
|
||||
}
|
||||
117
internal/client/virtual_port_test.go
Normal file
117
internal/client/virtual_port_test.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* ============================================================================
|
||||
* Projekt.....: rs2322tcp
|
||||
* Datei.......: internal/client/virtual_port_test.go
|
||||
* Copyright (C) 2026 Dieter Lang
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Beschreibung:
|
||||
* Tests für die lokale virtuelle seriellen Schnittstelle.
|
||||
* ============================================================================
|
||||
*/
|
||||
package client
|
||||
|
||||
import "testing"
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Tests
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func TestNewVirtualPort(t *testing.T) {
|
||||
serial := newTestVirtualSerial(nil)
|
||||
|
||||
port := NewVirtualPort("/dev/ttyUSB100", serial)
|
||||
|
||||
if port == nil {
|
||||
t.Fatal("NewVirtualPort() returned nil")
|
||||
}
|
||||
|
||||
if port.Path() != "/dev/ttyUSB100" {
|
||||
t.Errorf("Path() = %q, want %q",
|
||||
port.Path(), "/dev/ttyUSB100")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortRead(t *testing.T) {
|
||||
expected := []byte{0x46, 0x41, 0x00, 0x10, 0x0D}
|
||||
|
||||
serial := newTestVirtualSerial(expected)
|
||||
port := NewVirtualPort("/dev/ttyUSB100", serial)
|
||||
|
||||
buffer := make([]byte, len(expected))
|
||||
|
||||
n, err := port.Read(buffer)
|
||||
if err != nil {
|
||||
t.Fatalf("Read() failed: %v", err)
|
||||
}
|
||||
|
||||
if n != len(expected) {
|
||||
t.Fatalf("Read() returned %d bytes, want %d",
|
||||
n, len(expected))
|
||||
}
|
||||
|
||||
for i := range expected {
|
||||
if buffer[i] != expected[i] {
|
||||
t.Errorf("buffer[%d] = %02X, want %02X",
|
||||
i, buffer[i], expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortWrite(t *testing.T) {
|
||||
serial := newTestVirtualSerial(nil)
|
||||
port := NewVirtualPort("/dev/ttyUSB100", serial)
|
||||
|
||||
data := []byte{0x46, 0x41, 0x00, 0x10, 0x0D}
|
||||
|
||||
n, err := port.Write(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Write() failed: %v", err)
|
||||
}
|
||||
|
||||
if n != len(data) {
|
||||
t.Fatalf("Write() returned %d, want %d",
|
||||
n, len(data))
|
||||
}
|
||||
|
||||
written := serial.Written()
|
||||
|
||||
if len(written) != len(data) {
|
||||
t.Fatalf("underlying serial received %d bytes, want %d",
|
||||
len(written), len(data))
|
||||
}
|
||||
|
||||
for i := range data {
|
||||
if written[i] != data[i] {
|
||||
t.Errorf("written[%d] = %02X, want %02X",
|
||||
i, written[i], data[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortClose(t *testing.T) {
|
||||
serial := newTestVirtualSerial(nil)
|
||||
port := NewVirtualPort("/dev/ttyUSB100", serial)
|
||||
|
||||
if err := port.Close(); err != nil {
|
||||
t.Fatalf("Close() failed: %v", err)
|
||||
}
|
||||
|
||||
// Ein zweiter Close darf keinen Fehler erzeugen.
|
||||
if err := port.Close(); err != nil {
|
||||
t.Fatalf("second Close() failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualPortNil(t *testing.T) {
|
||||
var port *VirtualPort
|
||||
|
||||
if port.Path() != "" {
|
||||
t.Errorf("nil Path() = %q, want empty string", port.Path())
|
||||
}
|
||||
|
||||
if err := port.Close(); err != nil {
|
||||
t.Errorf("nil Close() failed: %v", err)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
@ -5,8 +5,8 @@ import "io"
|
|||
// VirtualSerial repräsentiert eine vom Client bereitgestellte
|
||||
// virtuelle serielle Schnittstelle.
|
||||
//
|
||||
// Path liefert den Namen der für die Anwendung sichtbaren
|
||||
// seriellen Schnittstelle, zum Beispiel /dev/pts/1.
|
||||
// Path liefert den Pfad des internen virtuellen seriellen Geräts,
|
||||
// beispielsweise /dev/pts/2.
|
||||
//
|
||||
// Das io.ReadWriteCloser-Interface repräsentiert die interne
|
||||
// Byte-Verbindung zum virtuellen seriellen Gerät.
|
||||
|
|
|
|||
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
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
Package config provides configuration types and JSON handling for rs2322tcp.
|
||||
|
||||
The server configuration describes the physical serial devices available
|
||||
on the remote system. The client configuration describes the connection
|
||||
to the server and the user's assignment of virtual serial ports to remote
|
||||
on the remote system. The client configuration describes the connection to
|
||||
the server and the user's assignment of virtual serial ports to remote
|
||||
devices.
|
||||
|
||||
Project: rs2322tcp
|
||||
|
|
@ -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.
|
||||
|
|
@ -50,8 +52,9 @@ type DeviceConfig struct {
|
|||
|
||||
// ClientConfig contains the complete client configuration.
|
||||
type ClientConfig struct {
|
||||
Server ServerConnectionConfig `json:"server"`
|
||||
VirtualPorts []VirtualPortConfig `json:"virtual_ports"`
|
||||
Server ServerConnectionConfig `json:"server"`
|
||||
VirtualPortRange VirtualPortRangeConfig `json:"virtual_port_range"`
|
||||
VirtualPorts []VirtualPortConfig `json:"virtual_ports"`
|
||||
}
|
||||
|
||||
// ServerConnectionConfig contains the connection information for the
|
||||
|
|
@ -61,11 +64,47 @@ type ServerConnectionConfig struct {
|
|||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
// VirtualPortRangeConfig defines the reserved range of /dev/ttyUSB device
|
||||
// numbers that may be used for virtual rs2322tcp ports.
|
||||
type VirtualPortRangeConfig struct {
|
||||
First int `json:"first"`
|
||||
Last int `json:"last"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Defaults
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const (
|
||||
// DefaultVirtualPortFirst is the first virtual USB serial port number
|
||||
// used when no virtual port range is specified.
|
||||
DefaultVirtualPortFirst = 100
|
||||
|
||||
// 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.
|
||||
func DefaultVirtualPortRange() VirtualPortRangeConfig {
|
||||
return VirtualPortRangeConfig{
|
||||
First: DefaultVirtualPortFirst,
|
||||
Last: DefaultVirtualPortLast,
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -80,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
|
||||
}
|
||||
|
|
@ -95,6 +138,12 @@ func LoadClient(filename string) (*ClientConfig, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// A missing virtual_port_range is intentionally supported for backwards
|
||||
// compatibility. Apply the documented default before validation.
|
||||
if cfg.VirtualPortRange.First == 0 && cfg.VirtualPortRange.Last == 0 {
|
||||
cfg.VirtualPortRange = DefaultVirtualPortRange()
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -125,6 +174,10 @@ func SaveClient(filename string, cfg *ClientConfig) error {
|
|||
return fmt.Errorf("client configuration is nil")
|
||||
}
|
||||
|
||||
if cfg.VirtualPortRange.First == 0 && cfg.VirtualPortRange.Last == 0 {
|
||||
cfg.VirtualPortRange = DefaultVirtualPortRange()
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -180,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")
|
||||
|
|
@ -189,21 +245,37 @@ func (cfg *ClientConfig) Validate() error {
|
|||
return fmt.Errorf("invalid server port: %d", cfg.Server.Port)
|
||||
}
|
||||
|
||||
if cfg.VirtualPortRange.First < DefaultVirtualPortFirst {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -219,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
|
||||
|
|
@ -238,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,30 +126,146 @@ 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,
|
||||
)
|
||||
}
|
||||
|
||||
if 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadClientCustomVirtualPortRange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filename := filepath.Join(dir, "client.json")
|
||||
|
||||
data := `{
|
||||
"server": {
|
||||
"address": "100.64.0.10",
|
||||
"port": 5000
|
||||
},
|
||||
"virtual_port_range": {
|
||||
"first": 200,
|
||||
"last": 299
|
||||
},
|
||||
"virtual_ports": []
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(filename, []byte(data), 0644); err != nil {
|
||||
t.Fatalf("write test configuration: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.LoadClient(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadClient() failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.VirtualPortRange.First != 200 {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -153,10 +282,15 @@ func TestSaveAndLoadClient(t *testing.T) {
|
|||
Address: "100.64.0.10",
|
||||
Port: 5000,
|
||||
},
|
||||
VirtualPortRange: config.VirtualPortRangeConfig{
|
||||
First: 200,
|
||||
Last: 299,
|
||||
},
|
||||
VirtualPorts: []config.VirtualPortConfig{
|
||||
{
|
||||
Port: "COM7",
|
||||
RemoteDevice: "radio",
|
||||
StartupBytes: "57 00 00 1F 20",
|
||||
},
|
||||
{
|
||||
Port: "COM8",
|
||||
|
|
@ -178,10 +312,16 @@ func TestSaveAndLoadClient(t *testing.T) {
|
|||
t.Errorf("loaded Server differs from original")
|
||||
}
|
||||
|
||||
if loaded.VirtualPortRange != original.VirtualPortRange {
|
||||
t.Errorf("loaded VirtualPortRange differs from original")
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -228,6 +368,7 @@ func TestClientValidationDuplicateVirtualPort(t *testing.T) {
|
|||
Server: config.ServerConnectionConfig{
|
||||
Port: 5000,
|
||||
},
|
||||
VirtualPortRange: config.DefaultVirtualPortRange(),
|
||||
VirtualPorts: []config.VirtualPortConfig{
|
||||
{
|
||||
Port: "COM7",
|
||||
|
|
@ -262,6 +403,7 @@ func TestClientValidationInvalidPort(t *testing.T) {
|
|||
Server: config.ServerConnectionConfig{
|
||||
Port: 0,
|
||||
},
|
||||
VirtualPortRange: config.DefaultVirtualPortRange(),
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err == nil {
|
||||
|
|
@ -269,6 +411,54 @@ func TestClientValidationInvalidPort(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestClientValidationVirtualPortRangeBelowReservedArea(t *testing.T) {
|
||||
cfg := &config.ClientConfig{
|
||||
Server: config.ServerConnectionConfig{
|
||||
Port: 5000,
|
||||
},
|
||||
VirtualPortRange: config.VirtualPortRangeConfig{
|
||||
First: 99,
|
||||
Last: 199,
|
||||
},
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("Validate() succeeded, want range-below-reserved-area error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientValidationVirtualPortRangeReversed(t *testing.T) {
|
||||
cfg := &config.ClientConfig{
|
||||
Server: config.ServerConnectionConfig{
|
||||
Port: 5000,
|
||||
},
|
||||
VirtualPortRange: config.VirtualPortRangeConfig{
|
||||
First: 200,
|
||||
Last: 100,
|
||||
},
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("Validate() succeeded, want reversed-range error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientValidationVirtualPortRangeValid(t *testing.T) {
|
||||
cfg := &config.ClientConfig{
|
||||
Server: config.ServerConnectionConfig{
|
||||
Port: 5000,
|
||||
},
|
||||
VirtualPortRange: config.VirtualPortRangeConfig{
|
||||
First: 200,
|
||||
Last: 299,
|
||||
},
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Example configurations
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -279,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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -290,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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -313,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -378,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 %d",
|
||||
list.Devices[0].BaudRate, 9600)
|
||||
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 %d",
|
||||
list.Devices[1].BaudRate, 4800)
|
||||
t.Errorf(
|
||||
"Devices[1].BaudRate = %d, want 4800",
|
||||
list.Devices[1].BaudRate,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -449,11 +697,17 @@ func TestRemoteDeviceJSON(t *testing.T) {
|
|||
}
|
||||
|
||||
if len(decoded.Devices) != 1 {
|
||||
t.Fatalf("len(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)
|
||||
|
|
|
|||
271
scripts/build.sh
271
scripts/build.sh
|
|
@ -2,30 +2,86 @@
|
|||
#
|
||||
# ============================================================================
|
||||
# Projekt.....: rs2322tcp
|
||||
# Datei.......: build.sh
|
||||
# Datei.......: scripts/build.sh
|
||||
#
|
||||
# Copyright (C) 2026 Dieter Lang
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# Beschreibung:
|
||||
# Erstellt die Linux- und Windows-Builds des Projektes.
|
||||
# Erstellt die Release-Builds des Projekts rs2322tcp.
|
||||
#
|
||||
# Die Versionsinformationen werden aus Git ermittelt und beim Build
|
||||
# über den Go-Linker in die Binärdateien eingebettet.
|
||||
# Server:
|
||||
# - Linux amd64
|
||||
# - Linux arm64
|
||||
# - Linux 386
|
||||
# - Linux arm
|
||||
#
|
||||
# Gebaut werden:
|
||||
# - Linux amd64: Client und Server
|
||||
# - Linux arm64: Server
|
||||
# - Windows amd64: Client
|
||||
# Client:
|
||||
# - Linux amd64
|
||||
# - Linux arm64
|
||||
# - Windows amd64
|
||||
# - Windows 386
|
||||
#
|
||||
# Der Server wird CGO-frei direkt mit Go gebaut.
|
||||
#
|
||||
# Der Fyne-basierte Client wird für Linux amd64 direkt gebaut.
|
||||
# Die Cross-Builds für Linux arm64 und Windows werden mit fyne-cross
|
||||
# in Docker erzeugt.
|
||||
#
|
||||
# Voraussetzungen:
|
||||
# - Go
|
||||
# - Git
|
||||
# - Docker
|
||||
# - fyne-cross
|
||||
#
|
||||
# Installation von fyne-cross:
|
||||
#
|
||||
# go install github.com/fyne-io/fyne-cross@latest
|
||||
#
|
||||
# Windows XP wird derzeit nicht unterstützt.
|
||||
# ============================================================================
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT=$(basename "$(pwd)")
|
||||
BUILDDIR="build"
|
||||
MODULE=$(go list -m)
|
||||
##############################################################################
|
||||
# Projektverzeichnis
|
||||
##############################################################################
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
cd "${PROJECT_DIR}"
|
||||
|
||||
PROJECT="$(basename "${PROJECT_DIR}")"
|
||||
BUILD_DIR="build"
|
||||
FYNE_CROSS_DIR="fyne-cross"
|
||||
|
||||
##############################################################################
|
||||
# fyne-cross
|
||||
##############################################################################
|
||||
|
||||
FYNE_CROSS="${HOME}/go/bin/fyne-cross"
|
||||
|
||||
if [ ! -x "${FYNE_CROSS}" ]; then
|
||||
echo
|
||||
echo "FEHLER: fyne-cross wurde nicht gefunden:"
|
||||
echo " ${FYNE_CROSS}"
|
||||
echo
|
||||
echo "Installation:"
|
||||
echo " go install github.com/fyne-io/fyne-cross@latest"
|
||||
echo
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FYNE_APP_ID="de.lang-dieter.rs2322tcp"
|
||||
|
||||
##############################################################################
|
||||
# Modul
|
||||
##############################################################################
|
||||
|
||||
MODULE="$(go list -m)"
|
||||
|
||||
##############################################################################
|
||||
# Farben
|
||||
|
|
@ -41,20 +97,22 @@ RESET="\033[0m"
|
|||
# Zeitmessung
|
||||
##############################################################################
|
||||
|
||||
START=$(date +%s)
|
||||
START="$(date +%s)"
|
||||
|
||||
##############################################################################
|
||||
# Projektinformationen
|
||||
##############################################################################
|
||||
|
||||
VERSION=$(git describe --tags --always 2>/dev/null || echo "dev")
|
||||
BUILD=$(git rev-list --count HEAD 2>/dev/null || echo "0")
|
||||
BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
|
||||
COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
GO_VERSION=$(go version | awk '{print $3}')
|
||||
HOST=$(hostname)
|
||||
DATE=$(date "+%Y-%m-%dT%H:%M:%S")
|
||||
VERSION="$(git describe --tags --always 2>/dev/null || echo "dev")"
|
||||
BUILD="$(git rev-list --count HEAD 2>/dev/null || echo "0")"
|
||||
BRANCH="$(git branch --show-current 2>/dev/null || echo "unknown")"
|
||||
COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
|
||||
GO_VERSION="$(go version | awk '{print $3}')"
|
||||
HOST="$(hostname)"
|
||||
DATE="$(date "+%Y-%m-%dT%H:%M:%S")"
|
||||
|
||||
##############################################################################
|
||||
# Kopf
|
||||
##############################################################################
|
||||
|
||||
echo
|
||||
|
|
@ -64,14 +122,14 @@ echo " ${PROJECT} Build Script"
|
|||
echo "============================================================"
|
||||
echo -e "${RESET}"
|
||||
|
||||
printf "%-12s %s\n" "Projekt:" "$PROJECT"
|
||||
printf "%-12s %s\n" "Version:" "$VERSION"
|
||||
printf "%-12s %s\n" "Build:" "$BUILD"
|
||||
printf "%-12s %s\n" "Branch:" "$BRANCH"
|
||||
printf "%-12s %s\n" "Commit:" "$COMMIT"
|
||||
printf "%-12s %s\n" "Go:" "$GO_VERSION"
|
||||
printf "%-12s %s\n" "Host:" "$HOST"
|
||||
printf "%-12s %s\n" "Datum:" "$DATE"
|
||||
printf "%-12s %s\n" "Projekt:" "${PROJECT}"
|
||||
printf "%-12s %s\n" "Version:" "${VERSION}"
|
||||
printf "%-12s %s\n" "Build:" "${BUILD}"
|
||||
printf "%-12s %s\n" "Branch:" "${BRANCH}"
|
||||
printf "%-12s %s\n" "Commit:" "${COMMIT}"
|
||||
printf "%-12s %s\n" "Go:" "${GO_VERSION}"
|
||||
printf "%-12s %s\n" "Host:" "${HOST}"
|
||||
printf "%-12s %s\n" "Datum:" "${DATE}"
|
||||
|
||||
echo
|
||||
|
||||
|
|
@ -97,119 +155,192 @@ echo -e "${YELLOW}Bereinige Module ...${RESET}"
|
|||
go mod tidy
|
||||
|
||||
##############################################################################
|
||||
# Build-Verzeichnis
|
||||
# Build-Verzeichnisse
|
||||
##############################################################################
|
||||
|
||||
echo -e "${YELLOW}Erzeuge Build-Verzeichnis ...${RESET}"
|
||||
|
||||
rm -rf "${BUILDDIR}"
|
||||
mkdir -p "${BUILDDIR}"
|
||||
rm -rf "${BUILD_DIR}"
|
||||
mkdir -p "${BUILD_DIR}"
|
||||
|
||||
##############################################################################
|
||||
# Linker-Optionen
|
||||
##############################################################################
|
||||
|
||||
LDFLAGS="-s -w \
|
||||
-X '${MODULE}/internal/version.Version=${VERSION}' \
|
||||
-X '${MODULE}/internal/version.Build=${BUILD}' \
|
||||
-X '${MODULE}/internal/version.Commit=${COMMIT}' \
|
||||
-X '${MODULE}/internal/version.Date=${DATE}'"
|
||||
-X ${MODULE}/internal/version.Version=${VERSION} \
|
||||
-X ${MODULE}/internal/version.Build=${BUILD} \
|
||||
-X ${MODULE}/internal/version.Commit=${COMMIT} \
|
||||
-X ${MODULE}/internal/version.Date=${DATE}"
|
||||
|
||||
##############################################################################
|
||||
# Buildfunktion
|
||||
# Direkter Go-Build
|
||||
##############################################################################
|
||||
|
||||
build_target() {
|
||||
|
||||
GOOS=$1
|
||||
GOARCH=$2
|
||||
TARGET=$3
|
||||
PROGRAM=$4
|
||||
OUTPUT=$5
|
||||
local GOOS="$1"
|
||||
local GOARCH="$2"
|
||||
local TARGET="$3"
|
||||
local PROGRAM="$4"
|
||||
local OUTPUT="$5"
|
||||
local CGO="$6"
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}Baue ${PROGRAM} für ${TARGET}${RESET}"
|
||||
|
||||
mkdir -p "${BUILDDIR}/${TARGET}"
|
||||
mkdir -p "${BUILD_DIR}/${TARGET}"
|
||||
|
||||
GOOS="${GOOS}" \
|
||||
GOARCH="${GOARCH}" \
|
||||
CGO_ENABLED=0 \
|
||||
CGO_ENABLED="${CGO}" \
|
||||
go build \
|
||||
-trimpath \
|
||||
-ldflags="${LDFLAGS}" \
|
||||
-o "${BUILDDIR}/${TARGET}/${OUTPUT}" \
|
||||
-o "${BUILD_DIR}/${TARGET}/${OUTPUT}" \
|
||||
"./cmd/${PROGRAM}"
|
||||
|
||||
(
|
||||
cd "${BUILDDIR}/${TARGET}"
|
||||
cd "${BUILD_DIR}/${TARGET}"
|
||||
sha256sum "${OUTPUT}" > "${OUTPUT}.sha256"
|
||||
)
|
||||
|
||||
SIZE=$(stat -c%s "${BUILDDIR}/${TARGET}/${OUTPUT}")
|
||||
SIZE_MB=$(awk "BEGIN {printf \"%.2f\", ${SIZE}/1024/1024}")
|
||||
local SIZE
|
||||
local SIZE_MB
|
||||
|
||||
SIZE="$(stat -c%s "${BUILD_DIR}/${TARGET}/${OUTPUT}")"
|
||||
SIZE_MB="$(awk "BEGIN {printf \"%.2f\", ${SIZE}/1024/1024}")"
|
||||
|
||||
echo -e "${GREEN}OK${RESET}"
|
||||
echo "Größe : ${SIZE_MB} MB"
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
# Linux amd64
|
||||
# Fyne-Cross-Build
|
||||
##############################################################################
|
||||
|
||||
build_fyne_target() {
|
||||
|
||||
local PLATFORM="$1"
|
||||
local ARCH="$2"
|
||||
local TARGET="$3"
|
||||
local OUTPUT="$4"
|
||||
local SOURCE
|
||||
local TARGET_DIR
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}Baue rs2322tcp-client für ${TARGET} mit fyne-cross${RESET}"
|
||||
|
||||
"${FYNE_CROSS}" "${PLATFORM}" \
|
||||
-arch="${ARCH}" \
|
||||
-app-id="${FYNE_APP_ID}" \
|
||||
-output="rs2322tcp-client" \
|
||||
"./cmd/rs2322tcp-client"
|
||||
|
||||
if [ "${PLATFORM}" = "windows" ]; then
|
||||
SOURCE="${FYNE_CROSS_DIR}/bin/${PLATFORM}-${ARCH}/rs2322tcp-client.exe"
|
||||
else
|
||||
SOURCE="${FYNE_CROSS_DIR}/bin/${PLATFORM}-${ARCH}/rs2322tcp-client"
|
||||
fi
|
||||
|
||||
TARGET_DIR="${BUILD_DIR}/${TARGET}"
|
||||
|
||||
if [ ! -f "${SOURCE}" ]; then
|
||||
echo
|
||||
echo -e "${RED}FEHLER: fyne-cross-Builddatei wurde nicht gefunden:${RESET}"
|
||||
echo " ${SOURCE}"
|
||||
echo
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${TARGET_DIR}"
|
||||
|
||||
cp "${SOURCE}" "${TARGET_DIR}/${OUTPUT}"
|
||||
|
||||
(
|
||||
cd "${TARGET_DIR}"
|
||||
sha256sum "${OUTPUT}" > "${OUTPUT}.sha256"
|
||||
)
|
||||
|
||||
local SIZE
|
||||
local SIZE_MB
|
||||
|
||||
SIZE="$(stat -c%s "${TARGET_DIR}/${OUTPUT}")"
|
||||
SIZE_MB="$(awk "BEGIN {printf \"%.2f\", ${SIZE}/1024/1024}")"
|
||||
|
||||
echo -e "${GREEN}OK${RESET}"
|
||||
echo "Größe : ${SIZE_MB} MB"
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
# Server-Builds
|
||||
#
|
||||
# Der Server ist CGO-frei und kann direkt cross-kompiliert werden.
|
||||
##############################################################################
|
||||
|
||||
build_target linux amd64 linux-amd64 \
|
||||
rs2322tcp-client rs2322tcp-client
|
||||
|
||||
build_target linux amd64 linux-amd64 \
|
||||
rs2322tcp-server rs2322tcp-server
|
||||
|
||||
##############################################################################
|
||||
# Linux arm64
|
||||
##############################################################################
|
||||
rs2322tcp-server rs2322tcp-server 0
|
||||
|
||||
build_target linux arm64 linux-arm64 \
|
||||
rs2322tcp-server rs2322tcp-server
|
||||
rs2322tcp-server rs2322tcp-server 0
|
||||
|
||||
build_target linux 386 linux-386 \
|
||||
rs2322tcp-server rs2322tcp-server 0
|
||||
|
||||
build_target linux arm linux-arm \
|
||||
rs2322tcp-server rs2322tcp-server 0
|
||||
|
||||
##############################################################################
|
||||
# Client-Builds
|
||||
##############################################################################
|
||||
|
||||
# Linux amd64
|
||||
build_target linux amd64 linux-amd64 \
|
||||
rs2322tcp-client rs2322tcp-client 1
|
||||
|
||||
# Linux arm64
|
||||
build_fyne_target linux arm64 linux-arm64 \
|
||||
rs2322tcp-client
|
||||
|
||||
# Windows amd64
|
||||
##############################################################################
|
||||
build_fyne_target windows amd64 windows-amd64 \
|
||||
rs2322tcp-client.exe
|
||||
|
||||
build_target windows amd64 windows-amd64 \
|
||||
rs2322tcp-client rs2322tcp-client.exe
|
||||
# Windows 386
|
||||
build_fyne_target windows 386 windows-386 \
|
||||
rs2322tcp-client.exe
|
||||
|
||||
##############################################################################
|
||||
# Abschluss
|
||||
##############################################################################
|
||||
|
||||
END=$(date +%s)
|
||||
RUNTIME=$((END-START))
|
||||
END="$(date +%s)"
|
||||
RUNTIME="$((END - START))"
|
||||
|
||||
TARGETS=4
|
||||
FILES=$(find "${BUILDDIR}" -type f | wc -l)
|
||||
FILES="$(find "${BUILD_DIR}" -type f | wc -l)"
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}"
|
||||
echo "============================================================"
|
||||
echo "Zusammenfassung"
|
||||
echo " Zusammenfassung"
|
||||
echo "============================================================"
|
||||
echo -e "${RESET}"
|
||||
|
||||
printf "%-12s %s\n" "Targets:" "$TARGETS"
|
||||
printf "%-12s %s\n" "Dateien:" "$FILES"
|
||||
printf "%-12s %s\n" "Builddauer:" "${RUNTIME} Sekunden"
|
||||
printf "%-12s %s\n" "Dateien:" "${FILES}"
|
||||
printf "%-12s %s\n" "Builddauer:" "${RUNTIME} Sekunden"
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}"
|
||||
echo "============================================================"
|
||||
echo "Erzeugte Build-Dateien"
|
||||
echo " Erzeugte Build-Dateien"
|
||||
echo "============================================================"
|
||||
echo -e "${RESET}"
|
||||
|
||||
find "${BUILDDIR}" -type f | sort
|
||||
find "${BUILD_DIR}" -type f | sort
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}"
|
||||
echo "============================================================"
|
||||
echo "Alle Builds erfolgreich abgeschlossen."
|
||||
echo " Alle Builds erfolgreich abgeschlossen."
|
||||
echo "============================================================"
|
||||
echo -e "${RESET}"
|
||||
|
|
|
|||
184
scripts/deploy.sh
Executable file
184
scripts/deploy.sh
Executable file
|
|
@ -0,0 +1,184 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_NAME="rs2322tcp"
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BUILD_DIR="${PROJECT_ROOT}/build"
|
||||
DIST_DIR="${PROJECT_ROOT}/dist"
|
||||
CONFIG_DIR="${PROJECT_ROOT}/configs"
|
||||
|
||||
VERSION="$(git -C "${PROJECT_ROOT}" describe --tags --always --dirty 2>/dev/null || echo "unknown")"
|
||||
BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
echo
|
||||
echo "============================================================"
|
||||
echo " rs2322tcp Deploy Script"
|
||||
echo "============================================================"
|
||||
echo
|
||||
echo "Projekt: ${PROJECT_NAME}"
|
||||
echo "Version: ${VERSION}"
|
||||
echo "Datum: ${BUILD_DATE}"
|
||||
echo
|
||||
|
||||
if [[ ! -d "${BUILD_DIR}" ]]; then
|
||||
echo "FEHLER: Build-Verzeichnis nicht gefunden:"
|
||||
echo " ${BUILD_DIR}"
|
||||
echo
|
||||
echo "Bitte zuerst ./scripts/build.sh ausführen."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${CONFIG_DIR}/server.json" ]]; then
|
||||
echo "FEHLER: Konfigurationsdatei nicht gefunden:"
|
||||
echo " ${CONFIG_DIR}/server.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${CONFIG_DIR}/client.json" ]]; then
|
||||
echo "FEHLER: Konfigurationsdatei nicht gefunden:"
|
||||
echo " ${CONFIG_DIR}/client.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Bereinige Distributions-Verzeichnis ..."
|
||||
rm -rf "${DIST_DIR}"
|
||||
mkdir -p "${DIST_DIR}"
|
||||
|
||||
create_linux_package() {
|
||||
local architecture="$1"
|
||||
local program="$2"
|
||||
local config_file="$3"
|
||||
local package_name="$4"
|
||||
|
||||
local source_dir="${BUILD_DIR}/${architecture}"
|
||||
local package_dir="${DIST_DIR}/${architecture}/${package_name}"
|
||||
|
||||
if [[ ! -f "${source_dir}/${program}" ]]; then
|
||||
echo "Überspringe ${package_name} für ${architecture}"
|
||||
echo "Build-Datei nicht gefunden: ${source_dir}/${program}"
|
||||
echo
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Erzeuge ${package_name} für ${architecture}"
|
||||
|
||||
rm -rf "${package_dir}"
|
||||
mkdir -p "${package_dir}"
|
||||
|
||||
cp "${source_dir}/${program}" "${package_dir}/"
|
||||
cp "${CONFIG_DIR}/${config_file}" "${package_dir}/"
|
||||
|
||||
tar -C "${DIST_DIR}/${architecture}" \
|
||||
-czf "${DIST_DIR}/${architecture}/${package_name}-${VERSION}.tar.gz" \
|
||||
"${package_name}"
|
||||
|
||||
(
|
||||
cd "${DIST_DIR}/${architecture}"
|
||||
sha256sum "${package_name}-${VERSION}.tar.gz" \
|
||||
> "${package_name}-${VERSION}.tar.gz.sha256"
|
||||
)
|
||||
|
||||
echo "OK"
|
||||
echo
|
||||
}
|
||||
|
||||
echo "Erzeuge Linux-Server-Pakete ..."
|
||||
echo
|
||||
|
||||
create_linux_package \
|
||||
"linux-amd64" \
|
||||
"rs2322tcp-server" \
|
||||
"server.json" \
|
||||
"rs2322tcp-server"
|
||||
|
||||
create_linux_package \
|
||||
"linux-arm64" \
|
||||
"rs2322tcp-server" \
|
||||
"server.json" \
|
||||
"rs2322tcp-server"
|
||||
|
||||
create_linux_package \
|
||||
"linux-386" \
|
||||
"rs2322tcp-server" \
|
||||
"server.json" \
|
||||
"rs2322tcp-server"
|
||||
|
||||
create_linux_package \
|
||||
"linux-arm" \
|
||||
"rs2322tcp-server" \
|
||||
"server.json" \
|
||||
"rs2322tcp-server"
|
||||
|
||||
echo "Erzeuge Linux-Client-Pakete ..."
|
||||
echo
|
||||
|
||||
create_linux_package \
|
||||
"linux-amd64" \
|
||||
"rs2322tcp-client" \
|
||||
"client.json" \
|
||||
"rs2322tcp-client"
|
||||
|
||||
create_linux_package \
|
||||
"linux-arm64" \
|
||||
"rs2322tcp-client" \
|
||||
"client.json" \
|
||||
"rs2322tcp-client"
|
||||
|
||||
echo "Erzeuge Windows-Client-Pakete ..."
|
||||
echo
|
||||
|
||||
create_windows_package() {
|
||||
local architecture="$1"
|
||||
local package_name="$2"
|
||||
|
||||
local source_dir="${BUILD_DIR}/${architecture}"
|
||||
local package_dir="${DIST_DIR}/${architecture}/${package_name}"
|
||||
|
||||
if [[ ! -f "${source_dir}/rs2322tcp-client.exe" ]]; then
|
||||
echo "Überspringe ${package_name} für ${architecture}"
|
||||
echo "Build-Datei nicht gefunden: ${source_dir}/rs2322tcp-client.exe"
|
||||
echo
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Erzeuge ${package_name} für ${architecture}"
|
||||
|
||||
rm -rf "${package_dir}"
|
||||
mkdir -p "${package_dir}"
|
||||
|
||||
cp "${source_dir}/rs2322tcp-client.exe" "${package_dir}/"
|
||||
cp "${CONFIG_DIR}/client.json" \
|
||||
"${package_dir}/client-config.json"
|
||||
|
||||
(
|
||||
cd "${DIST_DIR}/${architecture}"
|
||||
zip -qr "${package_name}-${VERSION}.zip" "${package_name}"
|
||||
|
||||
sha256sum "${package_name}-${VERSION}.zip" \
|
||||
> "${package_name}-${VERSION}.zip.sha256"
|
||||
)
|
||||
|
||||
echo "OK"
|
||||
echo
|
||||
}
|
||||
|
||||
create_windows_package \
|
||||
"windows-amd64" \
|
||||
"rs2322tcp-client"
|
||||
|
||||
create_windows_package \
|
||||
"windows-386" \
|
||||
"rs2322tcp-client"
|
||||
|
||||
echo "============================================================"
|
||||
echo " Erzeugte Distributions-Dateien"
|
||||
echo "============================================================"
|
||||
echo
|
||||
|
||||
find "${DIST_DIR}" -type f | sort | sed "s|${PROJECT_ROOT}/||"
|
||||
|
||||
echo
|
||||
echo "============================================================"
|
||||
echo " Deploy erfolgreich abgeschlossen."
|
||||
echo "============================================================"
|
||||
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