2026-06-13 20:00 GUI für Rotorsteuerung eingerichtet

This commit is contained in:
Dieter Lang 2026-06-14 16:14:15 +02:00
parent 3967c46264
commit 6d407cea87
9 changed files with 237 additions and 85 deletions

View file

@ -81,5 +81,5 @@ go mod tidy
weitermachen:
PROJEKT-STATUS Der gRPC-Server und alle drei Einzel-Clients (rig-client, rotor-client, relais-client) sind erfolgreich auf den neuen AuthService mit dynamischer Session-Token-Injektion und die korrigierten Protobuf-Feldnamen (Go 1.21 / Fyne v2.7) umgestellt und fehlerfrei lokal kompiliert. Die Konfigurationsschicht (internal/config/config.go) ist vollständig repariert.NÄCHSTER SCHRITT: Gemeinsamer Live-Test-Start aller vier Komponenten in getrennten Terminals und Überprüfung der flüssigen Vektornadel-Bewegung im Rotor-Kompass bei simulierter Antennenbewegung. Vorher aber noch ein buils.sh wie aus nachfolgendem Beispiel
PROJEKT-MERKER: SAT-ROTOR RADIALDESIGNAktueller Status: Beide Kompasse laufen geometrisch entkoppelt und nebeneinander im 300x300 Pixel-Format mit transparenten Kreisen. Azimut nutzt den vollen Ring. Elevation nutzt den bogenförmigen Viertelkreis (-5° bis 90°) und besitzt jetzt 6 dedizierte Bogen-Buttons (0°, 15°, 30°, 45°, 60°, 90°), die sich harmonisch an die Krümmung der Skala anschmiegen. Beim Drücken der Elevations-Tasten bleibt der Azimut unberührt.Morgiger Start:Einfügen der Himmelsrichtungs-Textmarker (N, O, S, W) per canvas.NewText.Start des Linux-I2C-Bus-Treibers für den Raspberry Pi.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -2,3 +2,9 @@
station_name = "DL1ABC"
server_address = "127.0.0.1:50051" # Im Live-Betrieb dann die Tailscale-IP
refresh_rate_ms = 250
[client.rotor]
type = "G-5500"
port = "/dev/ttyUSB0"
home_azimuth = 225.0 # Deine bevorzugte Home-Richtung (z.B. Süd-West)
home_elevation = 15.0 # Deine bevorzugte Home-Elevation

View file

@ -1,10 +1,11 @@
// Package main implementiert das eigenständige Fyne-GUI-Fenster für die Rotorsteuerung (Rotor Control).
// Es nutzt Fyne Data Binding für die UI und gRPC-Metadaten zur tokenbasierten Authentifizierung.
// Es nutzt Fyne Data Binding für die UI, visualisiert Azimut und Elevation nebeneinander mit entkoppelten Ist/Soll-Zeigern.
package main
import (
"context"
"fmt"
"image/color"
"log"
"math"
"os"
@ -26,22 +27,50 @@ import (
"remote-shack/internal/config"
)
// RotorClientGUI verwaltet alle UI-Komponenten und gRPC-Verbindungen des Rotor-Kompasses.
type FixedSizeLayout struct {
size fyne.Size
}
func NewFixedSizeLayout(size fyne.Size) fyne.Layout {
return &FixedSizeLayout{size: size}
}
func (f *FixedSizeLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
for _, obj := range objects {
if _, ok := obj.(*widget.Button); !ok {
obj.Resize(f.size)
obj.Move(fyne.NewPos(0, 0))
}
}
}
func (f *FixedSizeLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
return f.size
}
type RotorClientGUI struct {
window fyne.Window
grpcClient shack.RotorServiceClient
azBind binding.String
azNeedle *canvas.Line
azInput *widget.Entry
statusLabel *widget.Label // FIX: Neues Status-Label für konsistente Rechteanzeige
token string
window fyne.Window
grpcClient shack.RotorServiceClient
azBind binding.String
elBind binding.String
azNeedle *canvas.Line
azTargetNeedle *canvas.Line
elNeedle *canvas.Line
elTargetNeedle *canvas.Line
azInput *widget.Entry
elInput *widget.Entry
statusLabel *widget.Label
token string
homeAzimuth float64
homeElevation float64
targetAzimuth float64
targetElevation float64
}
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("=== Starte Remote-Shack Rotor-Client ===")
log.Println("=== Starte Remote-Shack Sat-Rotor-Client ===")
// Läuft auf der Client-Maschine und lädt die client-config.toml
cfg, err := config.LoadClientConfig("client-config.toml")
if err != nil { log.Fatalf("Config Fehler: %v", err) }
@ -51,21 +80,17 @@ func main() {
token = string(tokenBytes)
log.Printf("[Auth] Geteiltes Session-Token erfolgreich geladen: %s", token)
} else {
log.Println("[Auth] Keine aktive Sitzung vorhanden. Starte im Nur-Lesen-Modus...")
log.Println("[Auth] Keine active Sitzung vorhanden. Starte im Nur-Lesen-Modus...")
token = ""
}
unaryInterceptor := func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
if token != "" {
ctx = metadata.AppendToOutgoingContext(ctx, "operator-token", token)
}
if token != "" { ctx = metadata.AppendToOutgoingContext(ctx, "operator-token", token) }
return invoker(ctx, method, req, reply, cc, opts...)
}
streamInterceptor := func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
if token != "" {
ctx = metadata.AppendToOutgoingContext(ctx, "operator-token", token)
}
if token != "" { ctx = metadata.AppendToOutgoingContext(ctx, "operator-token", token) }
return streamer(ctx, desc, cc, method, opts...)
}
@ -78,113 +103,219 @@ func main() {
defer conn.Close()
gui := &RotorClientGUI{
grpcClient: shack.NewRotorServiceClient(conn),
azBind: binding.NewString(),
token: token,
grpcClient: shack.NewRotorServiceClient(conn),
azBind: binding.NewString(),
elBind: binding.NewString(),
token: token,
homeAzimuth: cfg.Client.Rotor.HomeAzimuth,
homeElevation: cfg.Client.Rotor.HomeElevation,
targetAzimuth: 0.0,
targetElevation: 0.0,
}
myApp := app.NewWithID("de.remote-shack.client.rotor")
gui.window = myApp.NewWindow("Remote-Shack: Rotor Control")
gui.window = myApp.NewWindow(fmt.Sprintf("Rotor Control - Typ: %s (%s)", cfg.Client.Rotor.Type, cfg.Client.Rotor.Port))
gui.buildUI()
gui.window.Resize(fyne.NewSize(300, 420)) // Höhe minimal angepasst für das neue Label
gui.window.Resize(fyne.NewSize(620, 620))
go gui.startLivePositionLoop()
gui.window.ShowAndRun()
}
// buildUI erzeugt alle visuellen Komponenten und verknüpft sie mit den Data-Bindings.
func (g *RotorClientGUI) buildUI() {
g.azNeedle = canvas.NewLine(theme.ForegroundColor())
g.azNeedle.StrokeWidth = 3
g.azNeedle.Position1 = fyne.NewPos(100, 100)
g.azNeedle.Position2 = fyne.NewPos(100, 20)
redColor := color.RGBA{R: 230, G: 30, B: 30, A: 255}
greenColor := color.RGBA{R: 30, G: 200, B: 30, A: 255}
g.azInput = widget.NewEntry()
g.azInput.SetPlaceHolder("Ziel-Azimut (z.B. 145)")
btn := widget.NewButton("Position Setzen", g.sendTargetPosition)
// 1. Azimut-Zentrum (Fixierte 300x300 Box, Center bei 150,150)
g.azNeedle = canvas.NewLine(redColor)
g.azNeedle.StrokeWidth = 4
g.azNeedle.Position1 = fyne.NewPos(150, 150)
g.azNeedle.Position2 = fyne.NewPos(150, 45)
g.azTargetNeedle = canvas.NewLine(greenColor)
g.azTargetNeedle.StrokeWidth = 3
g.azTargetNeedle.Position1 = fyne.NewPos(150, 150)
g.azTargetNeedle.Position2 = fyne.NewPos(150, 45)
azCircle := canvas.NewCircle(theme.ForegroundColor())
azCircle.StrokeWidth = 2
azCircle.FillColor = color.Transparent
azCircle.Resize(fyne.NewSize(210, 210))
azCircle.Move(fyne.NewPos(45, 45))
azDisplay := container.NewWithoutLayout(azCircle, g.azTargetNeedle, g.azNeedle)
azDisplay.Resize(fyne.NewSize(300, 300))
// 2. Tasten für den äußeren Windrosen-Ring (Radius 128 Pixel vom Center 150,150)
btnN := widget.NewButton("N (0°)", func() { g.executeDirectMove(0, g.targetElevation) })
btnNO := widget.NewButton("NO (45°)", func() { g.executeDirectMove(45, g.targetElevation) })
btnO := widget.NewButton("O (90°)", func() { g.executeDirectMove(90, g.targetElevation) })
btnSO := widget.NewButton("SO (135°)", func() { g.executeDirectMove(135, g.targetElevation) })
btnS := widget.NewButton("S (180°)", func() { g.executeDirectMove(180, g.targetElevation) })
btnSW := widget.NewButton("SW (225°)", func() { g.executeDirectMove(225, g.targetElevation) })
btnW := widget.NewButton("W (270°)", func() { g.executeDirectMove(270, g.targetElevation) })
btnNW := widget.NewButton("NW (315°)", func() { g.executeDirectMove(315, g.targetElevation) })
btnN.Move(fyne.NewPos(100, 2)); btnN.Resize(fyne.NewSize(100, 36))
btnNO.Move(fyne.NewPos(242, 40)); btnNO.Resize(fyne.NewSize(100, 36))
btnO.Move(fyne.NewPos(298, 132)); btnO.Resize(fyne.NewSize(100, 36))
btnSO.Move(fyne.NewPos(242, 224)); btnSO.Resize(fyne.NewSize(100, 36))
btnS.Move(fyne.NewPos(100, 262)); btnS.Resize(fyne.NewSize(100, 36))
btnSW.Move(fyne.NewPos(-42, 224)); btnSW.Resize(fyne.NewSize(100, 36))
btnW.Move(fyne.NewPos(-98, 132)); btnW.Resize(fyne.NewSize(100, 36))
btnNW.Move(fyne.NewPos(-42, 40)); btnNW.Resize(fyne.NewSize(100, 36))
rawWindrose := container.NewWithoutLayout(
azDisplay, btnN, btnNO, btnO, btnSO, btnS, btnSW, btnW, btnNW,
)
windroseContainer := container.New(NewFixedSizeLayout(fyne.NewSize(300, 300)), rawWindrose)
// 3. Elevation-Ausschnitt (Center links-unten bei 15,240, Grafikbox-Breite 240)
g.elNeedle = canvas.NewLine(redColor)
g.elNeedle.StrokeWidth = 4
g.elNeedle.Position1 = fyne.NewPos(15, 240)
g.elNeedle.Position2 = fyne.NewPos(235, 240)
g.elTargetNeedle = canvas.NewLine(greenColor)
g.elTargetNeedle.StrokeWidth = 3
g.elTargetNeedle.Position1 = fyne.NewPos(15, 240)
g.elTargetNeedle.Position2 = fyne.NewPos(235, 240)
elArc := canvas.NewCircle(theme.ForegroundColor())
elArc.StrokeWidth = 2
elArc.FillColor = color.Transparent
elArc.Resize(fyne.NewSize(440, 440))
elArc.Move(fyne.NewPos(-205, 20))
horizonLine := canvas.NewLine(theme.DisabledColor())
horizonLine.StrokeWidth = 1
horizonLine.Position1 = fyne.NewPos(15, 240)
horizonLine.Position2 = fyne.NewPos(235, 240)
// Bogenförmige Elevations-Tasten (Radius 240 Px vom Ursprung 15,240 nach rechts oben)
btnEl0 := widget.NewButton("0°", func() { g.executeDirectMove(g.targetAzimuth, 0) })
btnEl15 := widget.NewButton("15°", func() { g.executeDirectMove(g.targetAzimuth, 15) })
btnEl30 := widget.NewButton("30°", func() { g.executeDirectMove(g.targetAzimuth, 30) })
btnEl45 := widget.NewButton("45°", func() { g.executeDirectMove(g.targetAzimuth, 45) })
btnEl60 := widget.NewButton("60°", func() { g.executeDirectMove(g.targetAzimuth, 60) })
btnEl90 := widget.NewButton("90°", func() { g.executeDirectMove(g.targetAzimuth, 90) })
btnEl0.Move(fyne.NewPos(242, 224)); btnEl0.Resize(fyne.NewSize(56, 32))
btnEl15.Move(fyne.NewPos(232, 162)); btnEl15.Resize(fyne.NewSize(56, 32))
btnEl30.Move(fyne.NewPos(206, 105)); btnEl30.Resize(fyne.NewSize(56, 32))
btnEl45.Move(fyne.NewPos(166, 58)); btnEl45.Resize(fyne.NewSize(56, 32))
btnEl60.Move(fyne.NewPos(115, 24)); btnEl60.Resize(fyne.NewSize(56, 32))
btnEl90.Move(fyne.NewPos(15, 2)); btnEl90.Resize(fyne.NewSize(56, 32))
elDisplay := container.NewWithoutLayout(
elArc, horizonLine, g.elTargetNeedle, g.elNeedle,
btnEl0, btnEl15, btnEl30, btnEl45, btnEl60, btnEl90,
)
elDisplay.Resize(fyne.NewSize(300, 300))
// 4. Steuerungs- und Textelemente
g.azInput = widget.NewEntry(); g.azInput.SetPlaceHolder("Azimut°")
g.elInput = widget.NewEntry(); g.elInput.SetPlaceHolder("Elev°")
btnSet := widget.NewButtonWithIcon("Anfahren", theme.NavigateNextIcon(), g.sendManualPosition)
btnHome := widget.NewButtonWithIcon("HOME", theme.HomeIcon(), func() { g.executeDirectMove(g.homeAzimuth, g.homeElevation) })
// FIX: Status-Label analog zum Relais-Client initialisieren
g.statusLabel = widget.NewLabelWithStyle("Status: Synchronisiere...", fyne.TextAlignCenter, fyne.TextStyle{Italic: true})
compassContainer := container.NewWithoutLayout(g.azNeedle)
compassContainer.Resize(fyne.NewSize(200, 200))
displayHeader := container.NewGridWithColumns(2,
container.NewVBox(widget.NewLabelWithStyle("AZIMUT (0-360°)", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), container.NewCenter(windroseContainer), widget.NewLabelWithData(g.azBind)),
container.NewVBox(widget.NewLabelWithStyle("ELEVATION (-5° bis 90°)", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), container.NewCenter(elDisplay), widget.NewLabelWithData(g.elBind)),
)
g.window.SetContent(container.NewVBox(
widget.NewLabelWithStyle("--- ROTOR COMPASS ---", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
widget.NewLabelWithData(g.azBind),
container.NewCenter(compassContainer),
g.azInput,
btn,
content := container.NewVBox(
displayHeader,
widget.NewSeparator(),
g.statusLabel, // Konsistente Platzierung ganz unten im Fenster
))
container.NewGridWithColumns(2,
container.NewVBox(widget.NewLabelWithStyle("--- DIREKTZUGRIFF ---", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), container.NewCenter(btnHome)),
container.NewVBox(widget.NewLabelWithStyle("--- MANUELLE KOORDINATEN ---", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), container.NewGridWithColumns(3, g.azInput, g.elInput, btnSet)),
),
widget.NewSeparator(),
g.statusLabel,
)
g.window.SetContent(container.NewMax(content))
}
// startLivePositionLoop liest den Server-Status im Hintergrund und stößt UI-Updates via fyne.Do an.
func (g *RotorClientGUI) startLivePositionLoop() {
firstSync := true // Schützt den initialen RAM-Zustand beim Einschalten
for {
stream, err := g.grpcClient.LivePosition(context.Background(), &shack.Empty{})
if err != nil {
fyne.Do(func() {
g.statusLabel.SetText("Fehler: Keine Verbindung")
})
time.Sleep(2 * time.Second)
continue
fyne.Do(func() { g.statusLabel.SetText("Fehler: Keine Verbindung") })
time.Sleep(2 * time.Second); continue
}
for {
status, err := stream.Recv()
if err != nil { break }
_ = g.azBind.Set(fmt.Sprintf("Azimut: %.1f°", status.AzimuthIs))
// Dynamische Überprüfung der Rechte im Hintergrund-Loop für das Status-Label
_ = g.azBind.Set(fmt.Sprintf("Ist-Azimut: %.1f°", status.AzimuthIs))
_ = g.elBind.Set(fmt.Sprintf("Ist-Elevation: %.1f°", status.ElevationIs))
// FIX: Beim Programmstart oder Reconnect die RAM-Zielvariablen sofort mit den echten Ist-Werten füttern!
if firstSync {
g.targetAzimuth = status.AzimuthIs
g.targetElevation = status.ElevationIs
firstSync = false
}
tokenBytes, err := os.ReadFile(".shack_session")
hasRights := err == nil && len(tokenBytes) > 0
fyne.Do(func() {
if hasRights {
g.statusLabel.SetText("Verbindung: Aktiv (Operator)")
} else {
g.statusLabel.SetText("Verbindung: Nur Lesen (Keine Rechte)")
}
if hasRights { g.statusLabel.SetText("Verbindung: Aktiv (Operator)") } else { g.statusLabel.SetText("Verbindung: Nur Lesen (Keine Rechte)") }
rad := (status.AzimuthIs - 90) * math.Pi / 180.0
g.azNeedle.Position2 = fyne.NewPos(
float32(100.0+80.0*math.Cos(rad)),
float32(100.0+80.0*math.Sin(rad)),
)
// Azimut Ist-Vektor (Center 150,150)
radAz := (status.AzimuthIs - 90) * math.Pi / 180.0
g.azNeedle.Position2 = fyne.NewPos(float32(150.0+100.0*math.Cos(radAz)), float32(150.0+100.0*math.Sin(radAz)))
g.azNeedle.Refresh()
// Elevation Ist-Vektor (Center 15,240, Radius 220 Px)
radEl := (-status.ElevationIs) * math.Pi / 180.0
g.elNeedle.Position2 = fyne.NewPos(float32(15.0+220.0*math.Cos(radEl)), float32(240.0+220.0*math.Sin(radEl)))
g.elNeedle.Refresh()
})
}
}
}
// sendTargetPosition übermittelt den eingegebenen Richtwert an den gRPC-Server.
func (g *RotorClientGUI) sendTargetPosition() {
// FIX: Rechte-Prüfung direkt beim Klick abfangen und Senden softwareseitig blockieren
func (g *RotorClientGUI) sendManualPosition() {
az, _ := strconv.ParseFloat(g.azInput.Text, 64)
el, _ := strconv.ParseFloat(g.elInput.Text, 64)
g.executeDirectMove(az, el)
}
func (g *RotorClientGUI) executeDirectMove(az float64, el float64) {
tokenBytes, err := os.ReadFile(".shack_session")
if err != nil || len(tokenBytes) == 0 {
fyne.Do(func() {
g.statusLabel.SetText("⚠️ Blockiert: Keine Operator-Rechte!")
})
log.Println("[Sicherheit] Sendeversuch blockiert: Keine aktive Operator-Sitzung vorhanden.")
fyne.Do(func() { g.statusLabel.SetText("⚠️ Blockiert: Keine Operator-Rechte!") })
return
}
g.token = string(tokenBytes)
val, err := strconv.ParseFloat(g.azInput.Text, 64)
if err != nil {
log.Printf("Ungültige Eingabe: %v", err)
return
}
// FIX: Die Variablen werden jetzt absolut lückenlos im RAM festgehalten
g.targetAzimuth = az
g.targetElevation = el
fyne.Do(func() {
// Azimut Soll-Vektor (Center 150,150)
radAzTar := (az - 90) * math.Pi / 180.0
g.azTargetNeedle.Position2 = fyne.NewPos(float32(150.0+100.0*math.Cos(radAzTar)), float32(150.0+100.0*math.Sin(radAzTar)))
g.azTargetNeedle.Refresh()
// Elevation Soll-Vektor (Center 15,240)
radElTar := (-el) * math.Pi / 180.0
g.elTargetNeedle.Position2 = fyne.NewPos(float32(15.0+220.0*math.Cos(radElTar)), float32(240.0+220.0*math.Sin(radElTar)))
g.elTargetNeedle.Refresh()
})
ctx := metadata.AppendToOutgoingContext(context.Background(), "operator-token", g.token)
_, err = g.grpcClient.TargetPosition(ctx, &shack.RotorTarget{Azimuth: val})
if err != nil {
log.Printf("Fehler beim Senden der Zielposition: %v", err)
_, err = g.grpcClient.TargetPosition(ctx, &shack.RotorTarget{Azimuth: az, Elevation: el})
if err != nil {
log.Printf("Fehler beim Positionieren: %v", err)
} else {
fyne.Do(func() {
g.statusLabel.SetText("Verbindung: Aktiv (Positioniert)")
})
fyne.Do(func() { g.statusLabel.SetText(fmt.Sprintf("Fahre Position an: AZ %.1f° | EL %.1f°", az, el)) })
}
}

View file

@ -4,7 +4,7 @@ package config
import (
"os"
"github.com/BurntSushi/toml"
"github.com/BurntSushi/toml"
)
// ADS1115Module definiert die Hardware-Parameter und Kanal-Labels einer ADC-Platine.
@ -24,11 +24,20 @@ type ServerConfig struct {
Server ServerSettings `toml:"server"`
}
// RotorSettings definiert die hardware- und systemspezifischen Parameter eines Rotors.
type RotorSettings struct {
Type string `toml:"type"` // Z.B. "G-5500", "Spid", "Sat-Rotor"
Port string `toml:"port"` // Z.B. "/dev/ttyUSB0" oder "I2C"
HomeAzimuth float64 `toml:"home_azimuth"` // Standard-Richtung für Azimut (z.B. 180.0 für Süd)
HomeElevation float64 `toml:"home_elevation"` // Standard-Richtung für Elevation (z.B. 0.0)
}
// ClientSettings definiert die Verbindungsparameter für die Fyne-GUIs.
type ClientSettings struct {
ServerAddress string `toml:"server_address"`
StationName string `toml:"station_name"`
RefreshRateMs int `toml:"refresh_rate_ms"`
ServerAddress string `toml:"server_address"`
StationName string `toml:"station_name"`
RefreshRateMs int `toml:"refresh_rate_ms"`
Rotor RotorSettings `toml:"rotor"` // ERWEITERUNG: Rotorspezifische Einstellungen
}
// ClientConfig bündelt alle Konfigurationseinstellungen des GUI-Clients.
@ -68,6 +77,12 @@ func LoadClientConfig(path string) (*ClientConfig, error) {
ServerAddress: "127.0.0.1:50051",
StationName: "DL-UNKNOWN",
RefreshRateMs: 250,
Rotor: RotorSettings{
Type: "Standard",
Port: "Simulation",
HomeAzimuth: 180.0, // Standardmäßig Süden
HomeElevation: 0.0, // Standardmäßig Horizont
},
},
}