Gerätezuordnung in GUI verwalten und speichern

This commit is contained in:
Dieter Lang 2026-08-12 12:42:24 +02:00
parent 5e595fe460
commit 90189ab4dc
9 changed files with 897 additions and 201 deletions

View file

@ -2,6 +2,50 @@
Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert. Alle wesentlichen Änderungen am Projekt werden in dieser Datei dokumentiert.
## [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 ## [0.0.6] - 2026-08-10
### Added ### Added

View file

@ -26,6 +26,16 @@
"data_bits": 8, "data_bits": 8,
"parity": "none", "parity": "none",
"stop_bits": 1 "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
go.mod
View file

@ -7,6 +7,8 @@ require (
golang.org/x/sys v0.43.0 golang.org/x/sys v0.43.0
) )
require github.com/FyshOS/fancyfs v0.0.1 // indirect
require ( require (
fyne.io/fyne/v2 v2.8.0 fyne.io/fyne/v2 v2.8.0
fyne.io/systray v1.12.2 // indirect fyne.io/systray v1.12.2 // indirect
@ -29,7 +31,6 @@ require (
github.com/hack-pad/safejs v0.1.0 // indirect github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect

3
go.sum
View file

@ -4,11 +4,12 @@ fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= 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 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 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 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs= 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 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/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 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=

View file

@ -298,3 +298,32 @@ func (a *Application) Running() bool {
return a.runtime != nil && a.client != nil return a.runtime != nil && a.client != nil
} }
// 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
}

View file

@ -73,6 +73,9 @@ type VirtualPortRangeConfig struct {
// 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"`
@ -229,6 +232,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")
@ -239,30 +245,36 @@ func (cfg *ClientConfig) Validate() error {
} }
if cfg.VirtualPortRange.First < DefaultVirtualPortFirst { if cfg.VirtualPortRange.First < DefaultVirtualPortFirst {
return fmt.Errorf("virtual port range first must be >= %d: %d", return fmt.Errorf(
DefaultVirtualPortFirst, cfg.VirtualPortRange.First) "virtual port range first must be >= %d: %d",
DefaultVirtualPortFirst,
cfg.VirtualPortRange.First,
)
} }
if cfg.VirtualPortRange.Last < cfg.VirtualPortRange.First { if cfg.VirtualPortRange.Last < cfg.VirtualPortRange.First {
return fmt.Errorf("invalid virtual port range: %d-%d", return fmt.Errorf(
cfg.VirtualPortRange.First, cfg.VirtualPortRange.Last) "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
@ -278,11 +290,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
@ -297,7 +317,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

View file

@ -391,8 +391,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))
} }
} }

View file

@ -7,44 +7,43 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* Beschreibung: * Beschreibung:
* Grundgerüst der grafischen Benutzeroberfläche des rs2322tcp-Clients. * Grafische Benutzeroberfläche des rs2322tcp-Clients.
*
* Die GUI verwendet Fyne und stellt die Benutzeroberfläche für den
* technischen rs2322tcp-Client bereit.
* *
* Die eigentliche Kommunikation mit dem Server sowie die Verwaltung der * Die eigentliche Kommunikation mit dem Server sowie die Verwaltung der
* virtuellen seriellen Ports bleiben vollständig in internal/client. * virtuellen seriellen Ports bleiben vollständig in internal/client.
* *
* Der technische Client wird im Hintergrund gestartet, damit das Fyne- * Die Gerätezuordnung wird in einem separaten Fenster bearbeitet.
* Fenster unmittelbar angezeigt werden kann. Dadurch bleibt die GUI auch
* dann bedienbar, wenn der Server nicht erreichbar ist oder der Aufbau
* der Verbindung längere Zeit benötigt.
* *
* Aktualisierungen von Fyne-Widgets aus einer Hintergrund-Goroutine werden * Änderungen an der Gerätezuordnung werden zunächst ausschließlich im
* über fyne.Do() auf den Fyne-GUI-Thread übertragen. * Arbeitsspeicher des Zuordnungsfensters gehalten.
* *
* Die GUI stellt außerdem die Funktion "Server neu verbinden" bereit. * Beim Schließen des Zuordnungsfensters werden vorhandene Änderungen
* Dabei wird die bestehende technische Client-Anwendung beendet und * gespeichert.
* anschließend vollständig neu gestartet. Dadurch wird die aktuelle
* client.json erneut geladen.
* *
* Die Gerätezuordnung wird zunächst ausschließlich angezeigt. Änderungen * Wird dagegen das Hauptfenster geschlossen, während das Zuordnungsfenster
* an der Zuordnung und deren Speicherung werden in einem späteren * noch ungespeicherte Änderungen enthält, wird der Anwender gefragt, ob
* Entwicklungsschritt ergänzt. * diese Änderungen verworfen werden sollen.
* ============================================================================ * ============================================================================
*/ */
package gui package gui
import ( import (
"fmt" "fmt"
"sort"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"git.lang-dieter.de/rs2322tcp/internal/client" "git.lang-dieter.de/rs2322tcp/internal/client"
) )
///////////////////////////////////////////////////////////////////////////////
// App
///////////////////////////////////////////////////////////////////////////////
// App represents the graphical rs2322tcp client application. // App represents the graphical rs2322tcp client application.
// //
// App owns the Fyne window and the technical client application. The GUI // App owns the Fyne window and the technical client application. The GUI
@ -62,12 +61,17 @@ type App struct {
assignmentButton *widget.Button assignmentButton *widget.Button
assignmentWindow fyne.Window assignmentWindow fyne.Window
assignmentEditor *AssignmentEditor
} }
///////////////////////////////////////////////////////////////////////////////
// Construction
///////////////////////////////////////////////////////////////////////////////
// NewApp creates the graphical rs2322tcp client application. // NewApp creates the graphical rs2322tcp client application.
// //
// No server connection is established by NewApp. The GUI can therefore be // No network connection is established by NewApp. Start must be called
// displayed immediately after this function returns. // separately.
func NewApp( func NewApp(
fyneApp fyne.App, fyneApp fyne.App,
configFile string, configFile string,
@ -104,13 +108,21 @@ func NewApp(
app.buildContent() 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 return app, nil
} }
// buildContent creates the initial main-window content. ///////////////////////////////////////////////////////////////////////////////
// // Main window
// The layout is deliberately simple. More detailed status information and ///////////////////////////////////////////////////////////////////////////////
// the device-assignment dialog will be added in later development steps.
// buildContent creates the main-window content.
func (a *App) buildContent() { func (a *App) buildContent() {
title := widget.NewLabel("rs2322tcp Client") title := widget.NewLabel("rs2322tcp Client")
@ -125,20 +137,22 @@ func (a *App) buildContent() {
a.window.SetContent(content) a.window.SetContent(content)
} }
///////////////////////////////////////////////////////////////////////////////
// Client lifecycle
///////////////////////////////////////////////////////////////////////////////
// Start starts the technical client application in the background. // Start starts the technical client application in the background.
// //
// The Fyne window is deliberately not blocked by the network connection. // The Fyne window remains responsive while the connection to the server is
// The status shown by the GUI is updated through fyne.Do(), because the // established.
// technical client runs outside the Fyne GUI thread.
//
// While the initial connection is being established, the reconnect button
// is disabled. This prevents multiple concurrent connection attempts.
func (a *App) Start() { func (a *App) Start() {
if a == nil { if a == nil {
return return
} }
a.statusLabel.SetText("Server: Verbindung wird aufgebaut ...") a.statusLabel.SetText(
"Server: Verbindung wird aufgebaut ...",
)
a.errorLabel.SetText("") a.errorLabel.SetText("")
a.reconnectButton.Disable() a.reconnectButton.Disable()
@ -147,7 +161,9 @@ func (a *App) Start() {
if err != nil { if err != nil {
fyne.Do(func() { fyne.Do(func() {
a.statusLabel.SetText("Server: nicht verbunden") a.statusLabel.SetText(
"Server: nicht verbunden",
)
a.errorLabel.SetText( a.errorLabel.SetText(
fmt.Sprintf("Fehler: %v", err), fmt.Sprintf("Fehler: %v", err),
) )
@ -174,20 +190,14 @@ func (a *App) Start() {
} }
// reconnect starts a complete reconnect of the technical client. // reconnect starts a complete reconnect of the technical client.
//
// The reconnect operation is deliberately handled by
// client.Application.Reconnect(). The GUI therefore does not need to know
// how the technical client closes connections, reloads the configuration,
// connects to the server or starts the Runtime.
//
// While reconnecting, the button is disabled so that only one reconnect
// operation can be active at a time.
func (a *App) reconnect() { func (a *App) reconnect() {
if a == nil { if a == nil {
return return
} }
a.statusLabel.SetText("Server: Verbindung wird neu aufgebaut ...") a.statusLabel.SetText(
"Server: Verbindung wird neu aufgebaut ...",
)
a.errorLabel.SetText("") a.errorLabel.SetText("")
a.reconnectButton.Disable() a.reconnectButton.Disable()
@ -196,7 +206,9 @@ func (a *App) reconnect() {
if err != nil { if err != nil {
fyne.Do(func() { fyne.Do(func() {
a.statusLabel.SetText("Server: nicht verbunden") a.statusLabel.SetText(
"Server: nicht verbunden",
)
a.errorLabel.SetText( a.errorLabel.SetText(
fmt.Sprintf("Fehler: %v", err), fmt.Sprintf("Fehler: %v", err),
) )
@ -222,19 +234,17 @@ func (a *App) reconnect() {
}() }()
} }
// showAssignments displays the current local-to-remote device assignments. ///////////////////////////////////////////////////////////////////////////////
// // Device assignment
// This first implementation is deliberately read-only. It obtains the ///////////////////////////////////////////////////////////////////////////////
// configuration and the current server device list from client.Application
// and converts them through the GUI-independent assignment functions. // showAssignments opens the editable device-assignment window.
//
// No configuration is changed or written by this function.
func (a *App) showAssignments() { func (a *App) showAssignments() {
if a == nil { if a == nil {
return return
} }
// Do not create another assignment window when one is already open. // Nur ein Zuordnungsfenster gleichzeitig.
if a.assignmentWindow != nil { if a.assignmentWindow != nil {
a.assignmentWindow.Show() a.assignmentWindow.Show()
return return
@ -243,133 +253,395 @@ func (a *App) showAssignments() {
cfg := a.clientApplication.Config() cfg := a.clientApplication.Config()
devices := a.clientApplication.Devices() devices := a.clientApplication.Devices()
assignments := BuildAssignments(cfg, devices) editor := NewAssignmentEditor(
unassigned := UnassignedDevices(cfg, devices) cfg,
devices,
content := container.NewVBox(
widget.NewLabel("Lokale Zuordnungen"),
) )
if len(assignments) == 0 { window := a.fyneApp.NewWindow(
content.Add(
widget.NewLabel("Keine lokalen Zuordnungen vorhanden."),
)
} else {
for _, assignment := range assignments {
status := "nicht verfügbar"
if assignment.Available {
status = "verfügbar"
}
content.Add(
widget.NewLabel(
fmt.Sprintf(
"%s -> %s (%s)",
assignment.LocalPort,
assignment.RemoteDevice,
status,
),
),
)
}
}
content.Add(widget.NewSeparator())
content.Add(
widget.NewLabel("Server-Geräte ohne lokale Zuordnung"),
)
if len(unassigned) == 0 {
content.Add(
widget.NewLabel(
"Keine nicht verwendeten Server-Geräte.",
),
)
} else {
for _, device := range unassigned {
content.Add(
widget.NewLabel(
fmt.Sprintf(
"%s noch nicht verwendet",
device.ID,
),
),
)
}
}
assignmentWindow := a.fyneApp.NewWindow(
"Gerätezuordnung", "Gerätezuordnung",
) )
assignmentWindow.Resize(fyne.NewSize(650, 450))
// Remember the child window so it can be closed together with window.Resize(
// the main window. fyne.NewSize(650, 450),
a.assignmentWindow = assignmentWindow )
assignmentWindow.SetOnClosed(func() { a.assignmentWindow = window
// Only clear the reference if this is still the current a.assignmentEditor = editor
// assignment window.
if a.assignmentWindow == assignmentWindow { // 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.assignmentWindow = nil
a.assignmentEditor = nil
} }
}) })
assignmentWindow.SetContent( a.refreshAssignmentWindow(
container.NewBorder( window,
nil, editor,
widget.NewButton( )
"Schließen",
func() { window.Show()
assignmentWindow.Close() }
},
), // refreshAssignmentWindow rebuilds all assignment Select widgets.
nil, //
nil, // This is intentional. When a device is disconnected, its former port
container.NewVScroll(content), // 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},
), ),
) )
assignmentWindow.Show() 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,
)
} }
// closeAssignmentWindow closes the currently open device-assignment window. 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.
// //
// This is deliberately kept separate from Close() so that the child window // The "Schließen" button saves changed assignments. If there are no changes,
// can be closed without changing any configuration or technical client // the window is simply closed.
// state. func (a *App) handleAssignmentWindowClose(
func (a *App) closeAssignmentWindow() { 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 { if a == nil {
return return
} }
if a.assignmentWindow == nil { if a.assignmentEditor == nil ||
!a.assignmentEditor.Dirty() {
a.finishMainWindowClose()
return return
} }
window := a.assignmentWindow confirm := dialog.NewConfirm(
a.assignmentWindow = nil "Ungespeicherte Änderungen",
window.Close() "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. // Close closes the technical client application.
// //
// Any open child GUI window is closed first. The technical client is then // This method is also used by cmd/rs2322tcp-client after the Fyne main
// shut down. // 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() { func (a *App) Close() {
if a == nil { if a == nil {
return return
} }
a.closeAssignmentWindow() if a.assignmentWindow != nil {
window := a.assignmentWindow
window.SetCloseIntercept(nil)
window.Close()
a.assignmentWindow = nil
a.assignmentEditor = nil
}
_ = a.clientApplication.Close() _ = a.clientApplication.Close()
} }
// ShowAndRun displays the main window and starts the Fyne event loop. // ShowAndRun displays the main window and starts the Fyne event loop.
//
// This method blocks until the GUI application terminates.
func (a *App) ShowAndRun() { func (a *App) ShowAndRun() {
if a == nil { if a == nil {
return return
@ -379,9 +651,6 @@ func (a *App) ShowAndRun() {
} }
// Window returns the main application window. // Window returns the main application window.
//
// The method is provided for the program entry point and for later GUI
// initialization that needs access to the window.
func (a *App) Window() fyne.Window { func (a *App) Window() fyne.Window {
if a == nil { if a == nil {
return nil return nil

View file

@ -7,29 +7,34 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* Beschreibung: * Beschreibung:
* GUI-unabhängige Aufbereitung der Zuordnung zwischen lokalen virtuellen * GUI-unabhängige Aufbereitung und Bearbeitung der Zuordnung zwischen den
* seriellen Schnittstellen und den vom rs2322tcp-Server angebotenen Geräten. * vom rs2322tcp-Server angebotenen Geräten und den lokalen virtuellen
* seriellen Schnittstellen.
* *
* Die Datei enthält bewusst keine Fyne-Abhängigkeiten. * Die Datei enthält bewusst keine Fyne-Abhängigkeiten.
* *
* Die lokale Client-Konfiguration beschreibt ausschließlich die tatsächlich * Die Änderungen an der Gerätezuordnung werden zunächst ausschließlich im
* gewünschten Verbindungen. Physisch vorhandene /dev/ttyUSB-Schnittstellen * Arbeitsspeicher des Zuordnungsfensters gehalten. Das Speichern in die
* außerhalb des von rs2322tcp verwalteten virtuellen Bereichs werden hier * Client-Konfiguration erfolgt beim Schließen des Fensters.
* nicht betrachtet.
* *
* Ein Server-Gerät kann daher in drei Zuständen erscheinen: * Regeln:
* *
* 1. lokal zugeordnet und aktuell vom Server angeboten * - Ein Server-Gerät kann höchstens einer lokalen Schnittstelle zugeordnet
* -> verfügbar / verbunden * werden.
* *
* 2. lokal zugeordnet, aber momentan nicht vom Server angeboten * - Eine lokale Schnittstelle kann höchstens einem Server-Gerät zugeordnet
* -> nicht verfügbar * werden.
* *
* 3. vom Server angeboten, aber keiner lokalen Schnittstelle zugeordnet * - "nicht verbunden" ist ein gültiger Zustand.
* -> noch nicht verwendet
* *
* Nicht verwendete Server-Geräte werden nicht Bestandteil der * - Ist ein Gerät bereits verbunden, kann es nur auf seinen aktuellen Port
* Client-Konfiguration. * 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 package gui
@ -39,6 +44,15 @@ import (
"git.lang-dieter.de/rs2322tcp/internal/transport" "git.lang-dieter.de/rs2322tcp/internal/transport"
) )
///////////////////////////////////////////////////////////////////////////////
// Constants
///////////////////////////////////////////////////////////////////////////////
const (
// NotConnected is the GUI representation of an unassigned server device.
NotConnected = "nicht verbunden"
)
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
// Device assignment // Device assignment
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
@ -49,15 +63,262 @@ import (
// //
// Available gibt an, ob das konfigurierte Remote-Gerät momentan in der // Available gibt an, ob das konfigurierte Remote-Gerät momentan in der
// vom Server gelieferten Geräteliste vorhanden ist. // vom Server gelieferten Geräteliste vorhanden ist.
//
// Available == false bedeutet nicht, dass die Konfiguration ungültig ist.
// Das Gerät kann lediglich momentan nicht vom Server angeboten werden.
type DeviceAssignment struct { type DeviceAssignment struct {
LocalPort string LocalPort string
RemoteDevice string RemoteDevice string
Available bool 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 // Build assignments
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
@ -67,9 +328,6 @@ type DeviceAssignment struct {
// //
// Es werden ausschließlich die in der Client-Konfiguration vorhandenen // Es werden ausschließlich die in der Client-Konfiguration vorhandenen
// lokalen virtuellen Ports berücksichtigt. // lokalen virtuellen Ports berücksichtigt.
//
// Physisch vorhandene serielle Schnittstellen des lokalen Rechners werden
// hier nicht gesucht und nicht bewertet.
func BuildAssignments( func BuildAssignments(
cfg *config.ClientConfig, cfg *config.ClientConfig,
devices []transport.RemoteDeviceInfo, devices []transport.RemoteDeviceInfo,
@ -78,17 +336,24 @@ func BuildAssignments(
return nil return nil
} }
assignments := make([]DeviceAssignment, 0, len(cfg.VirtualPorts)) assignments := make(
[]DeviceAssignment,
0,
len(cfg.VirtualPorts),
)
for _, virtualPort := range cfg.VirtualPorts { for _, virtualPort := range cfg.VirtualPorts {
assignments = append(assignments, DeviceAssignment{ assignments = append(
assignments,
DeviceAssignment{
LocalPort: virtualPort.Port, LocalPort: virtualPort.Port,
RemoteDevice: virtualPort.RemoteDevice, RemoteDevice: virtualPort.RemoteDevice,
Available: remoteDeviceExists( Available: remoteDeviceExists(
virtualPort.RemoteDevice, virtualPort.RemoteDevice,
devices, devices,
), ),
}) },
)
} }
return assignments return assignments
@ -100,24 +365,28 @@ func BuildAssignments(
// UnassignedDevices liefert die vom Server angebotenen Geräte, die aktuell // UnassignedDevices liefert die vom Server angebotenen Geräte, die aktuell
// keiner lokalen virtuellen Schnittstelle zugeordnet sind. // keiner lokalen virtuellen Schnittstelle zugeordnet sind.
//
// Diese Geräte werden bewusst nicht in die Client-Konfiguration übernommen.
// Erst wenn der Anwender im GUI eine Zuordnung vornimmt, entsteht daraus
// ein Eintrag in ClientConfig.VirtualPorts.
func UnassignedDevices( func UnassignedDevices(
cfg *config.ClientConfig, cfg *config.ClientConfig,
devices []transport.RemoteDeviceInfo, devices []transport.RemoteDeviceInfo,
) []transport.RemoteDeviceInfo { ) []transport.RemoteDeviceInfo {
assigned := assignedRemoteDevices(cfg) assigned := assignedRemoteDevices(
cfg,
)
unassigned := make([]transport.RemoteDeviceInfo, 0) unassigned := make(
[]transport.RemoteDeviceInfo,
0,
)
for _, device := range devices { for _, device := range devices {
if assigned[device.ID] { if assigned[device.ID] {
continue continue
} }
unassigned = append(unassigned, device) unassigned = append(
unassigned,
device,
)
} }
return unassigned return unassigned
@ -127,14 +396,33 @@ func UnassignedDevices(
// Internal helpers // 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 // assignedRemoteDevices erzeugt eine Menge der Remote-Geräte, die bereits
// einer lokalen virtuellen Schnittstelle zugeordnet sind. // einer lokalen virtuellen Schnittstelle zugeordnet sind.
//
// Die Remote-Device-ID ist der stabile Schlüssel der Zuordnung.
func assignedRemoteDevices( func assignedRemoteDevices(
cfg *config.ClientConfig, cfg *config.ClientConfig,
) map[string]bool { ) map[string]bool {
assigned := make(map[string]bool) assigned := make(
map[string]bool,
)
if cfg == nil { if cfg == nil {
return assigned return assigned
@ -165,3 +453,33 @@ func remoteDeviceExists(
return false 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
}