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
|
Notizen.txt
|
||||||
|
|
||||||
# Build-Verzeichnis
|
# Build-Verzeichnis
|
||||||
/build/
|
/build/
|
||||||
|
|
||||||
|
# Distributions-Verzeichnis
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# Temporäre Arbeitsdateien von fyne-cross
|
||||||
|
/fyne-cross/
|
||||||
|
|
||||||
# Lokale Build-Binaries
|
# Lokale Build-Binaries
|
||||||
/rs2322tcp-client
|
/rs2322tcp-client
|
||||||
/rs2322tcp-server
|
/rs2322tcp-server
|
||||||
|
|
|
||||||
398
CHANGELOG.md
398
CHANGELOG.md
|
|
@ -2,58 +2,350 @@
|
||||||
|
|
||||||
Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert.
|
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
|
### 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`
|
- Abstraktion der virtuellen seriellen Schnittstelle über `VirtualSerial`
|
||||||
- Automatisierter bidirektionaler PTY-Test mit allen 256 möglichen Bytewerten
|
- 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
|
- 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`
|
- Implementierung der seriellen Schnittstellenschicht unter `internal/serial`
|
||||||
- Verwendung von `go.bug.st/serial v1.7.1` für die Ansteuerung der seriellen Schnittstellen
|
- 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
|
- Konfigurationsunterstützung für Baudrate, Datenbits, Parität und Stopbits
|
||||||
- Öffnen und Schließen serieller Schnittstellen über die interne Serial-Abstraktion
|
- Serielle Geräte können über Konfiguration definiert werden
|
||||||
- Lesen und Schreiben von RS232-Daten über die Serial-Abstraktion
|
- Server-Control-Verbindung
|
||||||
- Dynamische TCP-Data-Listener für konfigurierte Geräte
|
- Session-Verwaltung
|
||||||
- Session-Verwaltung für dynamische Data-Listener
|
- Dynamische Data-Ports pro Gerät
|
||||||
- Session-Verwaltung für aktive DataConnections
|
- Serverseitiger Aufbau der TCP-Data-Verbindung
|
||||||
- Bidirektionale Datenübertragung zwischen TCP und RS232
|
- `DataConnection` für die bidirektionale Datenübertragung
|
||||||
- Öffnen der seriellen Schnittstelle erst beim Aufbau einer tatsächlichen Data-Verbindung
|
- `DataListener` zur Annahme von Data-Verbindungen
|
||||||
- Begrenzung auf eine aktive Data-Verbindung pro konfiguriertem Gerät
|
- TCP → RS232 Datenübertragung
|
||||||
- Integrationstest mit virtuellen seriellen Schnittstellen über `socat`
|
- RS232 → TCP Datenübertragung
|
||||||
- Tests für TCP → RS232 und RS232 → TCP
|
|
||||||
- Tests für Session-Reconnect und Ressourcenverwaltung
|
|
||||||
- Client-Control-Verbindung unter `internal/client`
|
- Client-Control-Verbindung unter `internal/client`
|
||||||
- Clientseitige Verarbeitung von `HELLO`
|
- Clientseitige Verarbeitung von `HELLO`
|
||||||
- Clientseitige Abfrage der konfigurierten Geräte über `GET_DEVICES`
|
- 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
|
- Clientseitiger Aufbau der TCP-Data-Verbindung
|
||||||
- Integrationstest für die vollständige Client-/Server-Kommunikation
|
- Integrationstest für die vollständige Client-/Server-Kommunikation
|
||||||
- End-to-End-Test der TCP-/RS232-Datenübertragung in beide Richtungen
|
- End-to-End-Test der TCP-/RS232-Datenübertragung in beide Richtungen
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Go-Version des Projektes auf Go `1.25.0` aktualisiert
|
- Serial-Kommunikation vollständig über `go.bug.st/serial`
|
||||||
- `go.bug.st/serial` wird in Version `v1.7.1` verwendet
|
- Server öffnet serielle Schnittstellen erst bei aktiver Data-Verbindung
|
||||||
- `golang.org/x/sys v0.43.0` wird als indirekte Abhängigkeit verwendet
|
- Pro Gerät wird nur eine aktive Data-Verbindung zugelassen
|
||||||
- Die Serial-Bibliothek `go.bug.st/serial` wurde zusätzlich auf dem privaten Git-Server des Projektes als Ausfallsicherung gespiegelt:
|
- Session- und Ressourcenverwaltung überarbeitet
|
||||||
`git.lang-dieter.de/third-party/go-serial`
|
- Race Condition im `DataListener` behoben
|
||||||
- Der `DataListener` wurde nebenläufigkeitssicher implementiert
|
|
||||||
- Das Schließen eines `DataListener` kann gleichzeitig mit einem laufenden `Accept()` erfolgen
|
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- `go test ./...` erfolgreich
|
- Unit-Tests für Serial
|
||||||
- `go test -race ./...` erfolgreich
|
- Unit-Tests für Session
|
||||||
- Race Condition im `DataListener` erkannt und behoben
|
- 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
|
- Client-/Server-Integrationstest erfolgreich
|
||||||
- Race-Test des Client-/Server-Integrationstests 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
|
### 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:
|
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
|
eine serielle Schnittstelle öffnen und empfangene Rohdaten hexadezimal
|
||||||
anzeigen.
|
anzeigen.
|
||||||
|
|
||||||
Die TX-Funktion des Geräte-Simulators sowie die vollständige Integration der
|
Die interaktive TX-Funktion des Geräte-Simulators sowie die vollständige
|
||||||
virtuellen seriellen Schnittstelle in den Client-Datenpfad sind noch nicht
|
Integration der virtuellen seriellen Schnittstelle in den produktiven
|
||||||
implementiert.
|
Client-Datenpfad sind noch nicht abgeschlossen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [0.0.1] - 2026-08-09
|
## [0.0.1] - 2026-08-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Grundstruktur des Projektes `rs2322tcp`
|
- Initiale Projektstruktur
|
||||||
- Go-Modul `git.lang-dieter.de/rs2322tcp`
|
- Go-Modul und Build-Grundlagen
|
||||||
- separates Programmgerüst für `rs2322tcp-client`
|
- Basis für Server und Client
|
||||||
- separates Programmgerüst für `rs2322tcp-server`
|
- Konfigurationsgrundlagen
|
||||||
- gemeinsame Versionierungsinformationen
|
- Versionsverwaltung und Build-Metadaten
|
||||||
- Git-basierte Build-Informationen
|
- Erste Dokumentation
|
||||||
- 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.
|
|
||||||
|
|
|
||||||
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.
|
* ============================================================================
|
||||||
|
* Projekt.....: rs2322tcp
|
||||||
The client will provide a local virtual serial interface and
|
* Datei.......: cmd/rs2322tcp-client/main.go
|
||||||
transparently transport serial data over TCP to the rs2322tcp server.
|
* Copyright (C) 2026 Dieter Lang
|
||||||
|
*
|
||||||
At this stage the application only provides the basic program
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
structure and version information.
|
*
|
||||||
|
* Beschreibung:
|
||||||
Project: rs2322tcp
|
* Einstiegspunkt für den grafischen rs2322tcp-Client.
|
||||||
Module: git.lang-dieter.de/rs2322tcp
|
*
|
||||||
*/
|
* 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"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"
|
"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
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* Beschreibung:
|
* 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
|
* Der Simulator öffnet eine angegebene serielle Schnittstelle und protokolliert
|
||||||
* alle vom angeschlossenen System empfangenen Bytes als Hexdump.
|
* 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
|
* Der Simulator stellt bewusst keine eigene PTY-Erzeugung bereit. Für
|
||||||
* Entwicklung und Integrationstests kann ihm beispielsweise ein unter Linux
|
* Entwicklung und Integrationstests kann ihm beispielsweise ein unter Linux
|
||||||
* erzeugter PTY-Slave (/dev/pts/N) übergeben werden. Im späteren Testaufbau
|
* erzeugter PTY-Slave (/dev/pts/N) übergeben werden. Im späteren Testaufbau
|
||||||
* kann derselbe Simulator jedoch auch direkt mit einer echten seriellen
|
* kann derselbe Simulator jedoch auch direkt mit einer echten seriellen
|
||||||
* Schnittstelle verbunden werden.
|
* Schnittstelle verbunden werden.
|
||||||
*
|
*
|
||||||
* Der Simulator dient zunächst als passives Gegenstück zum
|
* Der Simulator dient zunächst als externes Gegenstück zum
|
||||||
* rs2322tcp-server. Er soll insbesondere ermöglichen, die vom Server an ein
|
* rs2322tcp-server. Er ermöglicht insbesondere, die vom Server an ein
|
||||||
* simuliertes Gerät übertragenen Bytes sichtbar zu machen.
|
* 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
|
* Eingabeformat:
|
||||||
* Bytes, automatische Geräteantworten, Protokollsimulation und das Aufzeichnen
|
*
|
||||||
* bzw. Wiedergeben von Datenströmen umfassen.
|
* 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:
|
* Aufruf:
|
||||||
*
|
*
|
||||||
|
|
@ -36,12 +49,16 @@
|
||||||
* Port: /dev/pts/7
|
* Port: /dev/pts/7
|
||||||
* Warte auf Daten ...
|
* 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
|
* 12:34:01.123 RX 5 Bytes
|
||||||
* 46 41 00 10 0D
|
* 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.
|
* erfolgt ausschließlich als Rohdaten in hexadezimaler Form.
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
|
|
@ -50,10 +67,14 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/hex"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
|
|
@ -92,6 +113,13 @@ func main() {
|
||||||
fmt.Printf("Port: %s\n", *port)
|
fmt.Printf("Port: %s\n", *port)
|
||||||
fmt.Println("Warte auf Daten ...")
|
fmt.Println("Warte auf Daten ...")
|
||||||
fmt.Println()
|
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
|
// Der Simulator arbeitet byteorientiert. Die Größe des Puffers ist
|
||||||
// bewusst ausreichend groß gewählt, damit auch größere Datenblöcke
|
// 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.
|
// printRX gibt einen empfangenen Datenblock als Hexdump aus.
|
||||||
//
|
//
|
||||||
// Die Daten werden absichtlich nicht als Text interpretiert. Dadurch bleiben
|
// Die Daten werden absichtlich nicht als Text interpretiert. Dadurch bleiben
|
||||||
// auch nicht druckbare Bytes und Bytes oberhalb von 0x7F sichtbar und können
|
// auch nicht druckbare Bytes und Bytes oberhalb von 0x7F sichtbar und können
|
||||||
// bei der Analyse serieller Protokolle eindeutig nachvollzogen werden.
|
// bei der Analyse serieller Protokolle eindeutig nachvollzogen werden.
|
||||||
func printRX(data []byte) {
|
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(
|
fmt.Printf(
|
||||||
"%s RX %d Bytes\n",
|
"%s %s %d Bytes\n",
|
||||||
time.Now().Format("15:04:05.000"),
|
time.Now().Format("15:04:05.000"),
|
||||||
|
direction,
|
||||||
len(data),
|
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.
|
Package main implements the rs2322tcp server application.
|
||||||
|
|
||||||
The server will provide the network endpoint for rs2322tcp clients
|
The server provides the network endpoint for rs2322tcp clients
|
||||||
and connect incoming connections to configured physical RS232
|
and connects incoming data connections to configured physical
|
||||||
interfaces.
|
RS232 interfaces.
|
||||||
|
|
||||||
At this stage the application only provides the basic program
|
|
||||||
structure and version information.
|
|
||||||
|
|
||||||
Project: rs2322tcp
|
Project: rs2322tcp
|
||||||
Module: git.lang-dieter.de/rs2322tcp
|
Module: git.lang-dieter.de/rs2322tcp
|
||||||
|
|
@ -14,11 +11,56 @@ Module: git.lang-dieter.de/rs2322tcp
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/server"
|
||||||
"git.lang-dieter.de/rs2322tcp/internal/version"
|
"git.lang-dieter.de/rs2322tcp/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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": {
|
"server": {
|
||||||
"address": "100.64.0.10",
|
"address": "127.0.0.1",
|
||||||
"port": 5000
|
"port": 5000
|
||||||
},
|
},
|
||||||
|
"virtual_port_range": {
|
||||||
|
"first": 100,
|
||||||
|
"last": 199
|
||||||
|
},
|
||||||
"virtual_ports": [
|
"virtual_ports": [
|
||||||
{
|
{
|
||||||
"port": "COM7",
|
"port": "/dev/ttyUSB100",
|
||||||
"remote_device": "radio"
|
"remote_device": "rotor",
|
||||||
},
|
"startup_bytes": "57 00 00 00 00 00 00 00 00 00 00 1F 20"
|
||||||
{
|
|
||||||
"port": "COM8",
|
|
||||||
"remote_device": "rotor"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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",
|
"address": "0.0.0.0",
|
||||||
"port": 5000
|
"port": 5000
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"hardware_error_response": "ERROR - HARDWARE NOT AVAILABLE",
|
||||||
|
|
||||||
|
"serial_monitor": true,
|
||||||
|
|
||||||
"devices": [
|
"devices": [
|
||||||
{
|
{
|
||||||
"id": "radio",
|
"id": "radio",
|
||||||
"name": "Funkgerät",
|
"name": "Funkgerät",
|
||||||
"serial_port": "/dev/ttyUSB0",
|
"serial_port": "/dev/ttyUSB1",
|
||||||
"baud_rate": 9600,
|
"baud_rate": 9600,
|
||||||
"data_bits": 8,
|
"data_bits": 8,
|
||||||
"parity": "none",
|
"parity": "none",
|
||||||
|
|
@ -16,11 +21,21 @@
|
||||||
{
|
{
|
||||||
"id": "rotor",
|
"id": "rotor",
|
||||||
"name": "Antennenrotor",
|
"name": "Antennenrotor",
|
||||||
"serial_port": "/dev/ttyUSB1",
|
"serial_port": "/dev/ttyUSB0",
|
||||||
"baud_rate": 4800,
|
"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,
|
"data_bits": 8,
|
||||||
"parity": "none",
|
"parity": "none",
|
||||||
"stop_bits": 1
|
"stop_bits": 1
|
||||||
}
|
}
|
||||||
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
go.mod
44
go.mod
|
|
@ -2,6 +2,46 @@ module git.lang-dieter.de/rs2322tcp
|
||||||
|
|
||||||
go 1.25.0
|
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 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc=
|
||||||
go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
|
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 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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
|
* Projekt.....: rs2322tcp
|
||||||
* Datei.......: control.go
|
* Datei.......: internal/client/control.go
|
||||||
* Copyright (C) 2026 Dieter Lang
|
* Copyright (C) 2026 Dieter Lang
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
"git.lang-dieter.de/rs2322tcp/internal/transport"
|
||||||
)
|
)
|
||||||
|
|
@ -28,6 +29,8 @@ import (
|
||||||
|
|
||||||
// Client represents a connection to an rs2322tcp server.
|
// Client represents a connection to an rs2322tcp server.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
reader *bufio.Reader
|
reader *bufio.Reader
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +82,9 @@ func (c *Client) Conn() net.Conn {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
return c.conn
|
return c.conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,12 +93,21 @@ func (c *Client) Conn() net.Conn {
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
func (c *Client) hello() error {
|
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")
|
return fmt.Errorf("client is not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := transport.WriteMessage(
|
if err := transport.WriteMessage(
|
||||||
c.conn,
|
conn,
|
||||||
transport.NewHello(),
|
transport.NewHello(),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("send hello: %w", err)
|
return fmt.Errorf("send hello: %w", err)
|
||||||
|
|
@ -101,7 +116,7 @@ func (c *Client) hello() error {
|
||||||
var response transport.HelloResponseMessage
|
var response transport.HelloResponseMessage
|
||||||
|
|
||||||
if err := transport.ReadMessage(
|
if err := transport.ReadMessage(
|
||||||
c.reader,
|
reader,
|
||||||
&response,
|
&response,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("read hello response: %w", err)
|
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.
|
// The returned list contains the session-specific dynamic TCP data ports.
|
||||||
func (c *Client) GetDevices() ([]transport.RemoteDeviceInfo, error) {
|
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")
|
return nil, fmt.Errorf("client is not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := transport.WriteMessage(
|
if err := transport.WriteMessage(
|
||||||
c.conn,
|
conn,
|
||||||
transport.NewGetDevices(),
|
transport.NewGetDevices(),
|
||||||
); err != nil {
|
); 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
|
var response transport.DeviceListMessage
|
||||||
|
|
||||||
if err := transport.ReadMessage(
|
if err := transport.ReadMessage(
|
||||||
c.reader,
|
reader,
|
||||||
&response,
|
&response,
|
||||||
); err != nil {
|
); 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 {
|
if response.Type != transport.MessageDeviceList {
|
||||||
|
|
@ -160,13 +190,25 @@ func (c *Client) GetDevices() ([]transport.RemoteDeviceInfo, error) {
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
// Close closes the client control connection.
|
// 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 {
|
func (c *Client) Close() error {
|
||||||
if c == nil || c.conn == nil {
|
if c == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := c.conn.Close()
|
c.mu.Lock()
|
||||||
|
|
||||||
|
conn := c.conn
|
||||||
c.conn = nil
|
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"
|
"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()) {
|
func startVirtualSerialPair(t *testing.T) (string, string, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|
@ -79,6 +89,10 @@ func startVirtualSerialPair(t *testing.T) (string, string, func()) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Read helper
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
func readExactWithTimeout(
|
func readExactWithTimeout(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
reader io.Reader,
|
reader io.Reader,
|
||||||
|
|
@ -105,6 +119,10 @@ func readExactWithTimeout(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Integration test
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
func TestClientServerSerialIntegration(t *testing.T) {
|
func TestClientServerSerialIntegration(t *testing.T) {
|
||||||
serialA, serialB, cleanup := startVirtualSerialPair(t)
|
serialA, serialB, cleanup := startVirtualSerialPair(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
@ -130,6 +148,7 @@ func TestClientServerSerialIntegration(t *testing.T) {
|
||||||
Address: "127.0.0.1",
|
Address: "127.0.0.1",
|
||||||
Port: controlPort,
|
Port: controlPort,
|
||||||
},
|
},
|
||||||
|
HardwareErrorResponse: testHardwareErrorResponse,
|
||||||
Devices: []config.DeviceConfig{
|
Devices: []config.DeviceConfig{
|
||||||
{
|
{
|
||||||
ID: "radio",
|
ID: "radio",
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,51 @@
|
||||||
//go:build linux
|
//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
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"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 {
|
type ptySerial struct {
|
||||||
masterFD int
|
masterFD atomic.Int64
|
||||||
slaveFD int
|
slaveFD atomic.Int64
|
||||||
path string
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Constructor
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// newVirtualSerial creates a new Linux PTY pair.
|
||||||
func newVirtualSerial() (VirtualSerial, error) {
|
func newVirtualSerial() (VirtualSerial, error) {
|
||||||
masterFD, err := unix.Open(
|
masterFD, err := unix.Open(
|
||||||
"/dev/ptmx",
|
"/dev/ptmx",
|
||||||
|
|
@ -60,7 +92,11 @@ func newVirtualSerial() (VirtualSerial, error) {
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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
|
closeSlave := true
|
||||||
|
|
@ -74,16 +110,23 @@ func newVirtualSerial() (VirtualSerial, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
serial := &ptySerial{
|
||||||
|
path: path,
|
||||||
|
}
|
||||||
|
|
||||||
|
serial.masterFD.Store(int64(masterFD))
|
||||||
|
serial.slaveFD.Store(int64(slaveFD))
|
||||||
|
|
||||||
closeMaster = false
|
closeMaster = false
|
||||||
closeSlave = false
|
closeSlave = false
|
||||||
|
|
||||||
return &ptySerial{
|
return serial, nil
|
||||||
masterFD: masterFD,
|
|
||||||
slaveFD: slaveFD,
|
|
||||||
path: path,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// PTY configuration
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
func configurePTY(fd int) error {
|
func configurePTY(fd int) error {
|
||||||
termios, err := unix.IoctlGetTermios(
|
termios, err := unix.IoctlGetTermios(
|
||||||
fd,
|
fd,
|
||||||
|
|
@ -134,33 +177,80 @@ func configurePTY(fd int) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// VirtualSerial implementation
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Path returns the PTY slave path.
|
||||||
func (p *ptySerial) Path() string {
|
func (p *ptySerial) Path() string {
|
||||||
return p.path
|
return p.path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read reads raw bytes from the PTY master.
|
||||||
func (p *ptySerial) Read(b []byte) (int, error) {
|
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) {
|
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 {
|
func (p *ptySerial) Close() error {
|
||||||
var firstErr error
|
var firstErr error
|
||||||
|
|
||||||
if p.masterFD >= 0 {
|
masterFD := int(p.masterFD.Swap(-1))
|
||||||
if err := unix.Close(p.masterFD); err != nil {
|
if masterFD >= 0 {
|
||||||
|
if err := unix.Close(masterFD); err != nil {
|
||||||
firstErr = err
|
firstErr = err
|
||||||
}
|
}
|
||||||
p.masterFD = -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.slaveFD >= 0 {
|
slaveFD := int(p.slaveFD.Swap(-1))
|
||||||
if err := unix.Close(p.slaveFD); err != nil && firstErr == nil {
|
if slaveFD >= 0 {
|
||||||
|
if err := unix.Close(slaveFD); err != nil && firstErr == nil {
|
||||||
firstErr = err
|
firstErr = err
|
||||||
}
|
}
|
||||||
p.slaveFD = -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return firstErr
|
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
|
// VirtualSerial repräsentiert eine vom Client bereitgestellte
|
||||||
// virtuelle serielle Schnittstelle.
|
// virtuelle serielle Schnittstelle.
|
||||||
//
|
//
|
||||||
// Path liefert den Namen der für die Anwendung sichtbaren
|
// Path liefert den Pfad des internen virtuellen seriellen Geräts,
|
||||||
// seriellen Schnittstelle, zum Beispiel /dev/pts/1.
|
// beispielsweise /dev/pts/2.
|
||||||
//
|
//
|
||||||
// Das io.ReadWriteCloser-Interface repräsentiert die interne
|
// Das io.ReadWriteCloser-Interface repräsentiert die interne
|
||||||
// Byte-Verbindung zum virtuellen seriellen Gerät.
|
// 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.
|
Package config provides configuration types and JSON handling for rs2322tcp.
|
||||||
|
|
||||||
The server configuration describes the physical serial devices available
|
The server configuration describes the physical serial devices available
|
||||||
on the remote system. The client configuration describes the connection
|
on the remote system. The client configuration describes the connection to
|
||||||
to the server and the user's assignment of virtual serial ports to remote
|
the server and the user's assignment of virtual serial ports to remote
|
||||||
devices.
|
devices.
|
||||||
|
|
||||||
Project: rs2322tcp
|
Project: rs2322tcp
|
||||||
|
|
@ -23,8 +23,10 @@ import (
|
||||||
|
|
||||||
// ServerConfig contains the complete server configuration.
|
// ServerConfig contains the complete server configuration.
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Listen ListenConfig `json:"listen"`
|
Listen ListenConfig `json:"listen"`
|
||||||
Devices []DeviceConfig `json:"devices"`
|
HardwareErrorResponse string `json:"hardware_error_response"`
|
||||||
|
SerialMonitor bool `json:"serial_monitor"`
|
||||||
|
Devices []DeviceConfig `json:"devices"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListenConfig contains the network listener configuration.
|
// ListenConfig contains the network listener configuration.
|
||||||
|
|
@ -50,8 +52,9 @@ type DeviceConfig struct {
|
||||||
|
|
||||||
// ClientConfig contains the complete client configuration.
|
// ClientConfig contains the complete client configuration.
|
||||||
type ClientConfig struct {
|
type ClientConfig struct {
|
||||||
Server ServerConnectionConfig `json:"server"`
|
Server ServerConnectionConfig `json:"server"`
|
||||||
VirtualPorts []VirtualPortConfig `json:"virtual_ports"`
|
VirtualPortRange VirtualPortRangeConfig `json:"virtual_port_range"`
|
||||||
|
VirtualPorts []VirtualPortConfig `json:"virtual_ports"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerConnectionConfig contains the connection information for the
|
// ServerConnectionConfig contains the connection information for the
|
||||||
|
|
@ -61,11 +64,47 @@ type ServerConnectionConfig struct {
|
||||||
Port int `json:"port"`
|
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
|
// VirtualPortConfig describes one local virtual serial port and the
|
||||||
// remote device assigned to it.
|
// 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 {
|
type VirtualPortConfig struct {
|
||||||
Port string `json:"port"`
|
Port string `json:"port"`
|
||||||
RemoteDevice string `json:"remote_device"`
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.HardwareErrorResponse == "" {
|
||||||
|
cfg.HardwareErrorResponse = DefaultHardwareErrorResponse
|
||||||
|
}
|
||||||
|
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +138,12 @@ func LoadClient(filename string) (*ClientConfig, error) {
|
||||||
return nil, err
|
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 {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +174,10 @@ func SaveClient(filename string, cfg *ClientConfig) error {
|
||||||
return fmt.Errorf("client configuration is nil")
|
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 {
|
if err := cfg.Validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -180,6 +233,9 @@ func (cfg *ServerConfig) Validate() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks the client configuration for basic errors.
|
// 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 {
|
func (cfg *ClientConfig) Validate() error {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return fmt.Errorf("client configuration is 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)
|
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)
|
ports := make(map[string]bool)
|
||||||
|
|
||||||
for i, virtualPort := range cfg.VirtualPorts {
|
for i, virtualPort := range cfg.VirtualPorts {
|
||||||
if virtualPort.Port == "" {
|
if virtualPort.Port == "" {
|
||||||
return fmt.Errorf("virtual port %d: port is empty", i)
|
return fmt.Errorf(
|
||||||
}
|
"virtual port %d: port is empty",
|
||||||
|
i,
|
||||||
if virtualPort.RemoteDevice == "" {
|
)
|
||||||
return fmt.Errorf("virtual port %q: remote device is empty",
|
|
||||||
virtualPort.Port)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ports[virtualPort.Port] {
|
if ports[virtualPort.Port] {
|
||||||
return fmt.Errorf("duplicate virtual port: %q",
|
return fmt.Errorf(
|
||||||
virtualPort.Port)
|
"duplicate virtual port: %q",
|
||||||
|
virtualPort.Port,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ports[virtualPort.Port] = true
|
ports[virtualPort.Port] = true
|
||||||
|
|
@ -219,11 +291,19 @@ func (cfg *ClientConfig) Validate() error {
|
||||||
func loadJSON(filename string, target interface{}) error {
|
func loadJSON(filename string, target interface{}) error {
|
||||||
data, err := os.ReadFile(filename)
|
data, err := os.ReadFile(filename)
|
||||||
if err != nil {
|
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 {
|
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
|
return nil
|
||||||
|
|
@ -238,7 +318,11 @@ func saveJSON(filename string, value interface{}) error {
|
||||||
data = append(data, '\n')
|
data = append(data, '\n')
|
||||||
|
|
||||||
if err := os.WriteFile(filename, data, 0644); err != nil {
|
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
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,13 @@ package config_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"git.lang-dieter.de/rs2322tcp/internal/config"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.lang-dieter.de/rs2322tcp/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
@ -50,14 +52,24 @@ func TestLoadServer(t *testing.T) {
|
||||||
t.Fatalf("LoadServer() failed: %v", err)
|
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" {
|
if cfg.Listen.Address != "0.0.0.0" {
|
||||||
t.Errorf("Listen.Address = %q, want %q",
|
t.Errorf(
|
||||||
cfg.Listen.Address, "0.0.0.0")
|
"Listen.Address = %q, want %q",
|
||||||
|
cfg.Listen.Address,
|
||||||
|
"0.0.0.0",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Listen.Port != 5000 {
|
if cfg.Listen.Port != 5000 {
|
||||||
t.Errorf("Listen.Port = %d, want %d",
|
t.Errorf("Listen.Port = %d, want %d", cfg.Listen.Port, 5000)
|
||||||
cfg.Listen.Port, 5000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.Devices) != 1 {
|
if len(cfg.Devices) != 1 {
|
||||||
|
|
@ -67,18 +79,19 @@ func TestLoadServer(t *testing.T) {
|
||||||
device := cfg.Devices[0]
|
device := cfg.Devices[0]
|
||||||
|
|
||||||
if device.ID != "radio" {
|
if device.ID != "radio" {
|
||||||
t.Errorf("Device.ID = %q, want %q",
|
t.Errorf("Device.ID = %q, want %q", device.ID, "radio")
|
||||||
device.ID, "radio")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if device.SerialPort != "/dev/ttyUSB0" {
|
if device.SerialPort != "/dev/ttyUSB0" {
|
||||||
t.Errorf("Device.SerialPort = %q, want %q",
|
t.Errorf(
|
||||||
device.SerialPort, "/dev/ttyUSB0")
|
"Device.SerialPort = %q, want %q",
|
||||||
|
device.SerialPort,
|
||||||
|
"/dev/ttyUSB0",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if device.BaudRate != 9600 {
|
if device.BaudRate != 9600 {
|
||||||
t.Errorf("Device.BaudRate = %d, want %d",
|
t.Errorf("Device.BaudRate = %d, want %d", device.BaudRate, 9600)
|
||||||
device.BaudRate, 9600)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,30 +126,146 @@ func TestLoadClient(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Server.Address != "100.64.0.10" {
|
if cfg.Server.Address != "100.64.0.10" {
|
||||||
t.Errorf("Server.Address = %q, want %q",
|
t.Errorf(
|
||||||
cfg.Server.Address, "100.64.0.10")
|
"Server.Address = %q, want %q",
|
||||||
|
cfg.Server.Address,
|
||||||
|
"100.64.0.10",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Server.Port != 5000 {
|
if cfg.Server.Port != 5000 {
|
||||||
t.Errorf("Server.Port = %d, want %d",
|
t.Errorf("Server.Port = %d, want %d", cfg.Server.Port, 5000)
|
||||||
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 {
|
if len(cfg.VirtualPorts) != 1 {
|
||||||
t.Fatalf("len(VirtualPorts) = %d, want 1",
|
t.Fatalf(
|
||||||
len(cfg.VirtualPorts))
|
"len(VirtualPorts) = %d, want 1",
|
||||||
|
len(cfg.VirtualPorts),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
virtualPort := cfg.VirtualPorts[0]
|
virtualPort := cfg.VirtualPorts[0]
|
||||||
|
|
||||||
if virtualPort.Port != "COM7" {
|
if virtualPort.Port != "COM7" {
|
||||||
t.Errorf("VirtualPort.Port = %q, want %q",
|
t.Errorf(
|
||||||
virtualPort.Port, "COM7")
|
"VirtualPort.Port = %q, want %q",
|
||||||
|
virtualPort.Port,
|
||||||
|
"COM7",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if virtualPort.RemoteDevice != "radio" {
|
if virtualPort.RemoteDevice != "radio" {
|
||||||
t.Errorf("VirtualPort.RemoteDevice = %q, want %q",
|
t.Errorf(
|
||||||
virtualPort.RemoteDevice, "radio")
|
"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",
|
Address: "100.64.0.10",
|
||||||
Port: 5000,
|
Port: 5000,
|
||||||
},
|
},
|
||||||
|
VirtualPortRange: config.VirtualPortRangeConfig{
|
||||||
|
First: 200,
|
||||||
|
Last: 299,
|
||||||
|
},
|
||||||
VirtualPorts: []config.VirtualPortConfig{
|
VirtualPorts: []config.VirtualPortConfig{
|
||||||
{
|
{
|
||||||
Port: "COM7",
|
Port: "COM7",
|
||||||
RemoteDevice: "radio",
|
RemoteDevice: "radio",
|
||||||
|
StartupBytes: "57 00 00 1F 20",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Port: "COM8",
|
Port: "COM8",
|
||||||
|
|
@ -178,10 +312,16 @@ func TestSaveAndLoadClient(t *testing.T) {
|
||||||
t.Errorf("loaded Server differs from original")
|
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) {
|
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(loaded.VirtualPorts),
|
||||||
len(original.VirtualPorts))
|
len(original.VirtualPorts),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range original.VirtualPorts {
|
for i := range original.VirtualPorts {
|
||||||
|
|
@ -228,6 +368,7 @@ func TestClientValidationDuplicateVirtualPort(t *testing.T) {
|
||||||
Server: config.ServerConnectionConfig{
|
Server: config.ServerConnectionConfig{
|
||||||
Port: 5000,
|
Port: 5000,
|
||||||
},
|
},
|
||||||
|
VirtualPortRange: config.DefaultVirtualPortRange(),
|
||||||
VirtualPorts: []config.VirtualPortConfig{
|
VirtualPorts: []config.VirtualPortConfig{
|
||||||
{
|
{
|
||||||
Port: "COM7",
|
Port: "COM7",
|
||||||
|
|
@ -262,6 +403,7 @@ func TestClientValidationInvalidPort(t *testing.T) {
|
||||||
Server: config.ServerConnectionConfig{
|
Server: config.ServerConnectionConfig{
|
||||||
Port: 0,
|
Port: 0,
|
||||||
},
|
},
|
||||||
|
VirtualPortRange: config.DefaultVirtualPortRange(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cfg.Validate(); err == nil {
|
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
|
// Example configurations
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
@ -279,8 +469,8 @@ func TestExampleServerConfig(t *testing.T) {
|
||||||
t.Fatalf("LoadServer() failed: %v", err)
|
t.Fatalf("LoadServer() failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.Devices) != 2 {
|
if len(cfg.Devices) != 3 {
|
||||||
t.Fatalf("len(Devices) = %d, want 2", len(cfg.Devices))
|
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)
|
t.Fatalf("LoadClient() failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.VirtualPorts) != 2 {
|
if len(cfg.VirtualPorts) != 1 {
|
||||||
t.Fatalf("len(VirtualPorts) = %d, want 2", len(cfg.VirtualPorts))
|
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()
|
remote := device.RemoteDevice()
|
||||||
|
|
||||||
if remote.ID != "radio" {
|
if remote.ID != "radio" {
|
||||||
t.Errorf("RemoteDevice.ID = %q, want %q",
|
t.Errorf(
|
||||||
remote.ID, "radio")
|
"RemoteDevice.ID = %q, want %q",
|
||||||
|
remote.ID,
|
||||||
|
"radio",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if remote.Name != "Funkgerät" {
|
if remote.Name != "Funkgerät" {
|
||||||
t.Errorf("RemoteDevice.Name = %q, want %q",
|
t.Errorf(
|
||||||
remote.Name, "Funkgerät")
|
"RemoteDevice.Name = %q, want %q",
|
||||||
|
remote.Name,
|
||||||
|
"Funkgerät",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if remote.BaudRate != 9600 {
|
if remote.BaudRate != 9600 {
|
||||||
t.Errorf("RemoteDevice.BaudRate = %d, want %d",
|
t.Errorf(
|
||||||
remote.BaudRate, 9600)
|
"RemoteDevice.BaudRate = %d, want %d",
|
||||||
|
remote.BaudRate,
|
||||||
|
9600,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if remote.DataBits != 8 {
|
if remote.DataBits != 8 {
|
||||||
t.Errorf("RemoteDevice.DataBits = %d, want %d",
|
t.Errorf(
|
||||||
remote.DataBits, 8)
|
"RemoteDevice.DataBits = %d, want %d",
|
||||||
|
remote.DataBits,
|
||||||
|
8,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if remote.Parity != "none" {
|
if remote.Parity != "none" {
|
||||||
t.Errorf("RemoteDevice.Parity = %q, want %q",
|
t.Errorf(
|
||||||
remote.Parity, "none")
|
"RemoteDevice.Parity = %q, want %q",
|
||||||
|
remote.Parity,
|
||||||
|
"none",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if remote.StopBits != 1 {
|
if remote.StopBits != 1 {
|
||||||
t.Errorf("RemoteDevice.StopBits = %d, want %d",
|
t.Errorf(
|
||||||
remote.StopBits, 1)
|
"RemoteDevice.StopBits = %d, want %d",
|
||||||
|
remote.StopBits,
|
||||||
|
1,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -378,28 +613,41 @@ func TestServerRemoteDevices(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if list.Devices[0].ID != "radio" {
|
if list.Devices[0].ID != "radio" {
|
||||||
t.Errorf("Devices[0].ID = %q, want %q",
|
t.Errorf(
|
||||||
list.Devices[0].ID, "radio")
|
"Devices[0].ID = %q, want %q",
|
||||||
|
list.Devices[0].ID,
|
||||||
|
"radio",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if list.Devices[1].ID != "rotor" {
|
if list.Devices[1].ID != "rotor" {
|
||||||
t.Errorf("Devices[1].ID = %q, want %q",
|
t.Errorf(
|
||||||
list.Devices[1].ID, "rotor")
|
"Devices[1].ID = %q, want %q",
|
||||||
|
list.Devices[1].ID,
|
||||||
|
"rotor",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if list.Devices[0].Name != "Funkgerät" {
|
if list.Devices[0].Name != "Funkgerät" {
|
||||||
t.Errorf("Devices[0].Name = %q, want %q",
|
t.Errorf(
|
||||||
list.Devices[0].Name, "Funkgerät")
|
"Devices[0].Name = %q, want %q",
|
||||||
|
list.Devices[0].Name,
|
||||||
|
"Funkgerät",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if list.Devices[0].BaudRate != 9600 {
|
if list.Devices[0].BaudRate != 9600 {
|
||||||
t.Errorf("Devices[0].BaudRate = %d, want %d",
|
t.Errorf(
|
||||||
list.Devices[0].BaudRate, 9600)
|
"Devices[0].BaudRate = %d, want 9600",
|
||||||
|
list.Devices[0].BaudRate,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if list.Devices[1].BaudRate != 4800 {
|
if list.Devices[1].BaudRate != 4800 {
|
||||||
t.Errorf("Devices[1].BaudRate = %d, want %d",
|
t.Errorf(
|
||||||
list.Devices[1].BaudRate, 4800)
|
"Devices[1].BaudRate = %d, want 4800",
|
||||||
|
list.Devices[1].BaudRate,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -449,11 +697,17 @@ func TestRemoteDeviceJSON(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(decoded.Devices) != 1 {
|
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" {
|
if decoded.Devices[0].ID != "radio" {
|
||||||
t.Errorf("Devices[0].ID = %q, want %q",
|
t.Errorf(
|
||||||
decoded.Devices[0].ID, "radio")
|
"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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"sync"
|
||||||
|
|
||||||
bugserial "go.bug.st/serial"
|
bugserial "go.bug.st/serial"
|
||||||
|
|
||||||
|
|
@ -28,6 +29,7 @@ import (
|
||||||
|
|
||||||
// Connection represents an opened serial connection.
|
// Connection represents an opened serial connection.
|
||||||
type Connection struct {
|
type Connection struct {
|
||||||
|
mu sync.Mutex
|
||||||
port bugserial.Port
|
port bugserial.Port
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,20 +128,36 @@ func createMode(device config.DeviceConfig) (*bugserial.Mode, error) {
|
||||||
|
|
||||||
// Read reads data from the serial connection.
|
// Read reads data from the serial connection.
|
||||||
func (c *Connection) Read(p []byte) (int, error) {
|
func (c *Connection) Read(p []byte) (int, error) {
|
||||||
if c == nil || c.port == nil {
|
if c == nil {
|
||||||
return 0, io.ErrClosedPipe
|
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.
|
// Write writes data to the serial connection.
|
||||||
func (c *Connection) Write(p []byte) (int, error) {
|
func (c *Connection) Write(p []byte) (int, error) {
|
||||||
if c == nil || c.port == nil {
|
if c == nil {
|
||||||
return 0, io.ErrClosedPipe
|
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.
|
// Close closes the serial connection.
|
||||||
func (c *Connection) Close() error {
|
func (c *Connection) Close() error {
|
||||||
if c == nil || c.port == nil {
|
if c == nil {
|
||||||
return 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
|
c.port = nil
|
||||||
|
|
||||||
return err
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return port.Close()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,7 @@ func (s *ControlServer) runDataListener(
|
||||||
dataConnection, err := NewDataConnection(
|
dataConnection, err := NewDataConnection(
|
||||||
tcpConn,
|
tcpConn,
|
||||||
serialConn,
|
serialConn,
|
||||||
|
s.config.HardwareErrorResponse,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf(
|
log.Printf(
|
||||||
|
|
@ -351,6 +352,11 @@ func (s *ControlServer) runDataListener(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dataConnection.SetSerialMonitor(
|
||||||
|
s.config.SerialMonitor,
|
||||||
|
device.SerialPort,
|
||||||
|
)
|
||||||
|
|
||||||
if err := session.AddDataConnection(
|
if err := session.AddDataConnection(
|
||||||
device.ID,
|
device.ID,
|
||||||
dataConnection,
|
dataConnection,
|
||||||
|
|
@ -362,6 +368,7 @@ func (s *ControlServer) runDataListener(
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_ = dataConnection.Close()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ func testServerConfig() *config.ServerConfig {
|
||||||
Address: "127.0.0.1",
|
Address: "127.0.0.1",
|
||||||
Port: 5000,
|
Port: 5000,
|
||||||
},
|
},
|
||||||
|
HardwareErrorResponse: testHardwareErrorResponse,
|
||||||
Devices: []config.DeviceConfig{
|
Devices: []config.DeviceConfig{
|
||||||
{
|
{
|
||||||
ID: "radio",
|
ID: "radio",
|
||||||
|
|
@ -451,6 +452,7 @@ func TestControlServerDataConnection(t *testing.T) {
|
||||||
Address: "127.0.0.1",
|
Address: "127.0.0.1",
|
||||||
Port: 5000,
|
Port: 5000,
|
||||||
},
|
},
|
||||||
|
HardwareErrorResponse: testHardwareErrorResponse,
|
||||||
Devices: []config.DeviceConfig{
|
Devices: []config.DeviceConfig{
|
||||||
{
|
{
|
||||||
ID: "radio",
|
ID: "radio",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/*
|
/*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* Projekt.....: rs2322tcp
|
* Projekt.....: rs2322tcp
|
||||||
* Datei.......: data_connection.go
|
* Datei.......: internal/server/data_connection.go
|
||||||
* Copyright (C) 2026 Dieter Lang
|
* Copyright (C) 2026 Dieter Lang
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
@ -16,6 +16,7 @@ package server
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
@ -31,12 +32,20 @@ import (
|
||||||
// TCP -> Serial
|
// TCP -> Serial
|
||||||
// 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
|
// The serial side is represented by an io.ReadWriteCloser so that this
|
||||||
// server component does not depend directly on the concrete serial
|
// server component does not depend directly on the concrete serial
|
||||||
// implementation.
|
// implementation.
|
||||||
type DataConnection struct {
|
type DataConnection struct {
|
||||||
tcp net.Conn
|
tcp net.Conn
|
||||||
serial io.ReadWriteCloser
|
serial io.ReadWriteCloser
|
||||||
|
hardwareErrorResponse string
|
||||||
|
|
||||||
|
serialMonitor bool
|
||||||
|
serialPort string
|
||||||
|
|
||||||
closeOnce sync.Once
|
closeOnce sync.Once
|
||||||
closeErr error
|
closeErr error
|
||||||
|
|
@ -50,6 +59,7 @@ type DataConnection struct {
|
||||||
func NewDataConnection(
|
func NewDataConnection(
|
||||||
tcp net.Conn,
|
tcp net.Conn,
|
||||||
serial io.ReadWriteCloser,
|
serial io.ReadWriteCloser,
|
||||||
|
hardwareErrorResponse string,
|
||||||
) (*DataConnection, error) {
|
) (*DataConnection, error) {
|
||||||
if tcp == nil {
|
if tcp == nil {
|
||||||
return nil, fmt.Errorf("TCP connection is nil")
|
return nil, fmt.Errorf("TCP connection is nil")
|
||||||
|
|
@ -59,9 +69,14 @@ func NewDataConnection(
|
||||||
return nil, fmt.Errorf("serial connection is nil")
|
return nil, fmt.Errorf("serial connection is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hardwareErrorResponse == "" {
|
||||||
|
return nil, fmt.Errorf("hardware error response is empty")
|
||||||
|
}
|
||||||
|
|
||||||
return &DataConnection{
|
return &DataConnection{
|
||||||
tcp: tcp,
|
tcp: tcp,
|
||||||
serial: serial,
|
serial: serial,
|
||||||
|
hardwareErrorResponse: hardwareErrorResponse,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,6 +102,153 @@ func (c *DataConnection) SerialConn() io.ReadWriteCloser {
|
||||||
return c.serial
|
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
|
// Run
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
@ -96,6 +258,10 @@ func (c *DataConnection) SerialConn() io.ReadWriteCloser {
|
||||||
// Run blocks until one of the two transfer directions terminates.
|
// Run blocks until one of the two transfer directions terminates.
|
||||||
// The other direction is then stopped and both connections are closed.
|
// 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.
|
// The first non-EOF transfer error is returned.
|
||||||
func (c *DataConnection) Run() error {
|
func (c *DataConnection) Run() error {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
|
|
@ -111,15 +277,13 @@ func (c *DataConnection) Run() error {
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
_, err := io.Copy(c.serial, c.tcp)
|
errCh <- c.copyTCPToSerial()
|
||||||
errCh <- err
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
_, err := io.Copy(c.tcp, c.serial)
|
errCh <- c.copySerialToTCP()
|
||||||
errCh <- err
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
err := <-errCh
|
err := <-errCh
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ package server_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -31,6 +32,8 @@ type testSerialConnection struct {
|
||||||
reader *bytes.Reader
|
reader *bytes.Reader
|
||||||
writer bytes.Buffer
|
writer bytes.Buffer
|
||||||
|
|
||||||
|
writeErr error
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
closed bool
|
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) {
|
func (s *testSerialConnection) Read(p []byte) (int, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|
||||||
|
|
@ -76,6 +87,10 @@ func (s *testSerialConnection) Write(p []byte) (int, error) {
|
||||||
return 0, io.ErrClosedPipe
|
return 0, io.ErrClosedPipe
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.writeErr != nil {
|
||||||
|
return 0, s.writeErr
|
||||||
|
}
|
||||||
|
|
||||||
return s.writer.Write(p)
|
return s.writer.Write(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,6 +131,7 @@ func TestNewDataConnection(t *testing.T) {
|
||||||
connection, err := server.NewDataConnection(
|
connection, err := server.NewDataConnection(
|
||||||
tcpServer,
|
tcpServer,
|
||||||
serial,
|
serial,
|
||||||
|
testHardwareErrorResponse,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewDataConnection() failed: %v", err)
|
t.Fatalf("NewDataConnection() failed: %v", err)
|
||||||
|
|
@ -136,6 +152,7 @@ func TestNewDataConnectionRejectsNilTCP(t *testing.T) {
|
||||||
connection, err := server.NewDataConnection(
|
connection, err := server.NewDataConnection(
|
||||||
nil,
|
nil,
|
||||||
serial,
|
serial,
|
||||||
|
testHardwareErrorResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -155,6 +172,7 @@ func TestNewDataConnectionRejectsNilSerial(t *testing.T) {
|
||||||
connection, err := server.NewDataConnection(
|
connection, err := server.NewDataConnection(
|
||||||
tcpServer,
|
tcpServer,
|
||||||
nil,
|
nil,
|
||||||
|
testHardwareErrorResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -179,6 +197,7 @@ func TestDataConnectionTCPToSerial(t *testing.T) {
|
||||||
connection, err := server.NewDataConnection(
|
connection, err := server.NewDataConnection(
|
||||||
tcpServer,
|
tcpServer,
|
||||||
serial,
|
serial,
|
||||||
|
testHardwareErrorResponse,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewDataConnection() failed: %v", err)
|
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
|
// Serial -> TCP
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
@ -238,6 +311,7 @@ func TestDataConnectionSerialToTCP(t *testing.T) {
|
||||||
connection, err := server.NewDataConnection(
|
connection, err := server.NewDataConnection(
|
||||||
tcpServer,
|
tcpServer,
|
||||||
serial,
|
serial,
|
||||||
|
testHardwareErrorResponse,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewDataConnection() failed: %v", err)
|
t.Fatalf("NewDataConnection() failed: %v", err)
|
||||||
|
|
@ -285,6 +359,7 @@ func TestDataConnectionClose(t *testing.T) {
|
||||||
connection, err := server.NewDataConnection(
|
connection, err := server.NewDataConnection(
|
||||||
tcpServer,
|
tcpServer,
|
||||||
serial,
|
serial,
|
||||||
|
testHardwareErrorResponse,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewDataConnection() failed: %v", err)
|
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
|
// DataHandler accepts TCP data connections and connects them to a serial
|
||||||
// device.
|
// device.
|
||||||
type DataHandler struct {
|
type DataHandler struct {
|
||||||
listener *DataListener
|
listener *DataListener
|
||||||
serialFactory SerialFactory
|
serialFactory SerialFactory
|
||||||
|
hardwareErrorResponse string
|
||||||
}
|
}
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
@ -53,6 +54,7 @@ type DataHandler struct {
|
||||||
func NewDataHandler(
|
func NewDataHandler(
|
||||||
listener *DataListener,
|
listener *DataListener,
|
||||||
serialFactory SerialFactory,
|
serialFactory SerialFactory,
|
||||||
|
hardwareErrorResponse string,
|
||||||
) (*DataHandler, error) {
|
) (*DataHandler, error) {
|
||||||
if listener == nil {
|
if listener == nil {
|
||||||
return nil, fmt.Errorf("data listener is nil")
|
return nil, fmt.Errorf("data listener is nil")
|
||||||
|
|
@ -62,9 +64,14 @@ func NewDataHandler(
|
||||||
return nil, fmt.Errorf("serial factory is nil")
|
return nil, fmt.Errorf("serial factory is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hardwareErrorResponse == "" {
|
||||||
|
return nil, fmt.Errorf("hardware error response is empty")
|
||||||
|
}
|
||||||
|
|
||||||
return &DataHandler{
|
return &DataHandler{
|
||||||
listener: listener,
|
listener: listener,
|
||||||
serialFactory: serialFactory,
|
serialFactory: serialFactory,
|
||||||
|
hardwareErrorResponse: hardwareErrorResponse,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,6 +121,7 @@ func (h *DataHandler) handleConnection(tcpConn net.Conn) {
|
||||||
dataConnection, err := NewDataConnection(
|
dataConnection, err := NewDataConnection(
|
||||||
tcpConn,
|
tcpConn,
|
||||||
serialConn,
|
serialConn,
|
||||||
|
h.hardwareErrorResponse,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("create data connection: %v", err)
|
log.Printf("create data connection: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,12 @@ import (
|
||||||
"git.lang-dieter.de/rs2322tcp/internal/server"
|
"git.lang-dieter.de/rs2322tcp/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Test constants
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
const testHardwareErrorResponse = "TEST ERROR RESPONSE"
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
// Test serial connection
|
// Test serial connection
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
@ -119,6 +125,7 @@ func TestNewDataHandler(t *testing.T) {
|
||||||
func() (io.ReadWriteCloser, error) {
|
func() (io.ReadWriteCloser, error) {
|
||||||
return serial, nil
|
return serial, nil
|
||||||
},
|
},
|
||||||
|
testHardwareErrorResponse,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewDataHandler() failed: %v", err)
|
t.Fatalf("NewDataHandler() failed: %v", err)
|
||||||
|
|
@ -135,6 +142,7 @@ func TestNewDataHandlerRejectsNilListener(t *testing.T) {
|
||||||
func() (io.ReadWriteCloser, error) {
|
func() (io.ReadWriteCloser, error) {
|
||||||
return newHandlerTestSerial(nil), nil
|
return newHandlerTestSerial(nil), nil
|
||||||
},
|
},
|
||||||
|
testHardwareErrorResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -156,6 +164,7 @@ func TestNewDataHandlerRejectsNilFactory(t *testing.T) {
|
||||||
handler, err := server.NewDataHandler(
|
handler, err := server.NewDataHandler(
|
||||||
listener,
|
listener,
|
||||||
nil,
|
nil,
|
||||||
|
testHardwareErrorResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -185,6 +194,7 @@ func TestDataHandlerTCPToSerial(t *testing.T) {
|
||||||
func() (io.ReadWriteCloser, error) {
|
func() (io.ReadWriteCloser, error) {
|
||||||
return serial, nil
|
return serial, nil
|
||||||
},
|
},
|
||||||
|
testHardwareErrorResponse,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewDataHandler() failed: %v", err)
|
t.Fatalf("NewDataHandler() failed: %v", err)
|
||||||
|
|
@ -255,6 +265,7 @@ func TestDataHandlerSerialToTCP(t *testing.T) {
|
||||||
func() (io.ReadWriteCloser, error) {
|
func() (io.ReadWriteCloser, error) {
|
||||||
return serial, nil
|
return serial, nil
|
||||||
},
|
},
|
||||||
|
testHardwareErrorResponse,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewDataHandler() failed: %v", err)
|
t.Fatalf("NewDataHandler() failed: %v", err)
|
||||||
|
|
@ -311,6 +322,7 @@ func TestDataHandlerSerialFactoryError(t *testing.T) {
|
||||||
func() (io.ReadWriteCloser, error) {
|
func() (io.ReadWriteCloser, error) {
|
||||||
return nil, io.ErrClosedPipe
|
return nil, io.ErrClosedPipe
|
||||||
},
|
},
|
||||||
|
testHardwareErrorResponse,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewDataHandler() failed: %v", err)
|
t.Fatalf("NewDataHandler() failed: %v", err)
|
||||||
|
|
|
||||||
271
scripts/build.sh
271
scripts/build.sh
|
|
@ -2,30 +2,86 @@
|
||||||
#
|
#
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Projekt.....: rs2322tcp
|
# Projekt.....: rs2322tcp
|
||||||
# Datei.......: build.sh
|
# Datei.......: scripts/build.sh
|
||||||
#
|
#
|
||||||
# Copyright (C) 2026 Dieter Lang
|
# Copyright (C) 2026 Dieter Lang
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# Beschreibung:
|
# 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
|
# Server:
|
||||||
# über den Go-Linker in die Binärdateien eingebettet.
|
# - Linux amd64
|
||||||
|
# - Linux arm64
|
||||||
|
# - Linux 386
|
||||||
|
# - Linux arm
|
||||||
#
|
#
|
||||||
# Gebaut werden:
|
# Client:
|
||||||
# - Linux amd64: Client und Server
|
# - Linux amd64
|
||||||
# - Linux arm64: Server
|
# - Linux arm64
|
||||||
# - Windows amd64: Client
|
# - 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
|
set -e
|
||||||
|
|
||||||
PROJECT=$(basename "$(pwd)")
|
##############################################################################
|
||||||
BUILDDIR="build"
|
# Projektverzeichnis
|
||||||
MODULE=$(go list -m)
|
##############################################################################
|
||||||
|
|
||||||
|
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
|
# Farben
|
||||||
|
|
@ -41,20 +97,22 @@ RESET="\033[0m"
|
||||||
# Zeitmessung
|
# Zeitmessung
|
||||||
##############################################################################
|
##############################################################################
|
||||||
|
|
||||||
START=$(date +%s)
|
START="$(date +%s)"
|
||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
# Projektinformationen
|
# Projektinformationen
|
||||||
##############################################################################
|
##############################################################################
|
||||||
|
|
||||||
VERSION=$(git describe --tags --always 2>/dev/null || echo "dev")
|
VERSION="$(git describe --tags --always 2>/dev/null || echo "dev")"
|
||||||
BUILD=$(git rev-list --count HEAD 2>/dev/null || echo "0")
|
BUILD="$(git rev-list --count HEAD 2>/dev/null || echo "0")"
|
||||||
BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
|
BRANCH="$(git branch --show-current 2>/dev/null || echo "unknown")"
|
||||||
COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
|
||||||
GO_VERSION=$(go version | awk '{print $3}')
|
GO_VERSION="$(go version | awk '{print $3}')"
|
||||||
HOST=$(hostname)
|
HOST="$(hostname)"
|
||||||
DATE=$(date "+%Y-%m-%dT%H:%M:%S")
|
DATE="$(date "+%Y-%m-%dT%H:%M:%S")"
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Kopf
|
||||||
##############################################################################
|
##############################################################################
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|
@ -64,14 +122,14 @@ echo " ${PROJECT} Build Script"
|
||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
echo -e "${RESET}"
|
echo -e "${RESET}"
|
||||||
|
|
||||||
printf "%-12s %s\n" "Projekt:" "$PROJECT"
|
printf "%-12s %s\n" "Projekt:" "${PROJECT}"
|
||||||
printf "%-12s %s\n" "Version:" "$VERSION"
|
printf "%-12s %s\n" "Version:" "${VERSION}"
|
||||||
printf "%-12s %s\n" "Build:" "$BUILD"
|
printf "%-12s %s\n" "Build:" "${BUILD}"
|
||||||
printf "%-12s %s\n" "Branch:" "$BRANCH"
|
printf "%-12s %s\n" "Branch:" "${BRANCH}"
|
||||||
printf "%-12s %s\n" "Commit:" "$COMMIT"
|
printf "%-12s %s\n" "Commit:" "${COMMIT}"
|
||||||
printf "%-12s %s\n" "Go:" "$GO_VERSION"
|
printf "%-12s %s\n" "Go:" "${GO_VERSION}"
|
||||||
printf "%-12s %s\n" "Host:" "$HOST"
|
printf "%-12s %s\n" "Host:" "${HOST}"
|
||||||
printf "%-12s %s\n" "Datum:" "$DATE"
|
printf "%-12s %s\n" "Datum:" "${DATE}"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|
||||||
|
|
@ -97,119 +155,192 @@ echo -e "${YELLOW}Bereinige Module ...${RESET}"
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
# Build-Verzeichnis
|
# Build-Verzeichnisse
|
||||||
##############################################################################
|
##############################################################################
|
||||||
|
|
||||||
echo -e "${YELLOW}Erzeuge Build-Verzeichnis ...${RESET}"
|
echo -e "${YELLOW}Erzeuge Build-Verzeichnis ...${RESET}"
|
||||||
|
|
||||||
rm -rf "${BUILDDIR}"
|
rm -rf "${BUILD_DIR}"
|
||||||
mkdir -p "${BUILDDIR}"
|
mkdir -p "${BUILD_DIR}"
|
||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
# Linker-Optionen
|
# Linker-Optionen
|
||||||
##############################################################################
|
##############################################################################
|
||||||
|
|
||||||
LDFLAGS="-s -w \
|
LDFLAGS="-s -w \
|
||||||
-X '${MODULE}/internal/version.Version=${VERSION}' \
|
-X ${MODULE}/internal/version.Version=${VERSION} \
|
||||||
-X '${MODULE}/internal/version.Build=${BUILD}' \
|
-X ${MODULE}/internal/version.Build=${BUILD} \
|
||||||
-X '${MODULE}/internal/version.Commit=${COMMIT}' \
|
-X ${MODULE}/internal/version.Commit=${COMMIT} \
|
||||||
-X '${MODULE}/internal/version.Date=${DATE}'"
|
-X ${MODULE}/internal/version.Date=${DATE}"
|
||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
# Buildfunktion
|
# Direkter Go-Build
|
||||||
##############################################################################
|
##############################################################################
|
||||||
|
|
||||||
build_target() {
|
build_target() {
|
||||||
|
|
||||||
GOOS=$1
|
local GOOS="$1"
|
||||||
GOARCH=$2
|
local GOARCH="$2"
|
||||||
TARGET=$3
|
local TARGET="$3"
|
||||||
PROGRAM=$4
|
local PROGRAM="$4"
|
||||||
OUTPUT=$5
|
local OUTPUT="$5"
|
||||||
|
local CGO="$6"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}Baue ${PROGRAM} für ${TARGET}${RESET}"
|
echo -e "${BLUE}Baue ${PROGRAM} für ${TARGET}${RESET}"
|
||||||
|
|
||||||
mkdir -p "${BUILDDIR}/${TARGET}"
|
mkdir -p "${BUILD_DIR}/${TARGET}"
|
||||||
|
|
||||||
GOOS="${GOOS}" \
|
GOOS="${GOOS}" \
|
||||||
GOARCH="${GOARCH}" \
|
GOARCH="${GOARCH}" \
|
||||||
CGO_ENABLED=0 \
|
CGO_ENABLED="${CGO}" \
|
||||||
go build \
|
go build \
|
||||||
-trimpath \
|
-trimpath \
|
||||||
-ldflags="${LDFLAGS}" \
|
-ldflags="${LDFLAGS}" \
|
||||||
-o "${BUILDDIR}/${TARGET}/${OUTPUT}" \
|
-o "${BUILD_DIR}/${TARGET}/${OUTPUT}" \
|
||||||
"./cmd/${PROGRAM}"
|
"./cmd/${PROGRAM}"
|
||||||
|
|
||||||
(
|
(
|
||||||
cd "${BUILDDIR}/${TARGET}"
|
cd "${BUILD_DIR}/${TARGET}"
|
||||||
sha256sum "${OUTPUT}" > "${OUTPUT}.sha256"
|
sha256sum "${OUTPUT}" > "${OUTPUT}.sha256"
|
||||||
)
|
)
|
||||||
|
|
||||||
SIZE=$(stat -c%s "${BUILDDIR}/${TARGET}/${OUTPUT}")
|
local SIZE
|
||||||
SIZE_MB=$(awk "BEGIN {printf \"%.2f\", ${SIZE}/1024/1024}")
|
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 -e "${GREEN}OK${RESET}"
|
||||||
echo "Größe : ${SIZE_MB} MB"
|
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 \
|
build_target linux amd64 linux-amd64 \
|
||||||
rs2322tcp-client rs2322tcp-client
|
rs2322tcp-server rs2322tcp-server 0
|
||||||
|
|
||||||
build_target linux amd64 linux-amd64 \
|
|
||||||
rs2322tcp-server rs2322tcp-server
|
|
||||||
|
|
||||||
##############################################################################
|
|
||||||
# Linux arm64
|
|
||||||
##############################################################################
|
|
||||||
|
|
||||||
build_target linux arm64 linux-arm64 \
|
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
|
# Windows amd64
|
||||||
##############################################################################
|
build_fyne_target windows amd64 windows-amd64 \
|
||||||
|
rs2322tcp-client.exe
|
||||||
|
|
||||||
build_target windows amd64 windows-amd64 \
|
# Windows 386
|
||||||
rs2322tcp-client rs2322tcp-client.exe
|
build_fyne_target windows 386 windows-386 \
|
||||||
|
rs2322tcp-client.exe
|
||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
# Abschluss
|
# Abschluss
|
||||||
##############################################################################
|
##############################################################################
|
||||||
|
|
||||||
END=$(date +%s)
|
END="$(date +%s)"
|
||||||
RUNTIME=$((END-START))
|
RUNTIME="$((END - START))"
|
||||||
|
|
||||||
TARGETS=4
|
FILES="$(find "${BUILD_DIR}" -type f | wc -l)"
|
||||||
FILES=$(find "${BUILDDIR}" -type f | wc -l)
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}"
|
echo -e "${BLUE}"
|
||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
echo "Zusammenfassung"
|
echo " Zusammenfassung"
|
||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
echo -e "${RESET}"
|
echo -e "${RESET}"
|
||||||
|
|
||||||
printf "%-12s %s\n" "Targets:" "$TARGETS"
|
printf "%-12s %s\n" "Dateien:" "${FILES}"
|
||||||
printf "%-12s %s\n" "Dateien:" "$FILES"
|
printf "%-12s %s\n" "Builddauer:" "${RUNTIME} Sekunden"
|
||||||
printf "%-12s %s\n" "Builddauer:" "${RUNTIME} Sekunden"
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}"
|
echo -e "${BLUE}"
|
||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
echo "Erzeugte Build-Dateien"
|
echo " Erzeugte Build-Dateien"
|
||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
echo -e "${RESET}"
|
echo -e "${RESET}"
|
||||||
|
|
||||||
find "${BUILDDIR}" -type f | sort
|
find "${BUILD_DIR}" -type f | sort
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo -e "${GREEN}"
|
echo -e "${GREEN}"
|
||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
echo "Alle Builds erfolgreich abgeschlossen."
|
echo " Alle Builds erfolgreich abgeschlossen."
|
||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
echo -e "${RESET}"
|
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