Gerätezuordnung in GUI verwalten und speichern
This commit is contained in:
parent
5e595fe460
commit
90189ab4dc
9 changed files with 897 additions and 201 deletions
44
CHANGELOG.md
44
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
3
go.mod
|
|
@ -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
3
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue