2026-06-14 17:36 GUI für control-rig verbessert

This commit is contained in:
Dieter Lang 2026-06-14 17:36:57 +02:00
parent 6d407cea87
commit c954c2292a
5 changed files with 284 additions and 122 deletions

Binary file not shown.

Binary file not shown.

View file

@ -3,7 +3,7 @@ set -e
echo "🔧 Baue Remote-Shack (Reiner Kompilierungs-Sync)..."
# Clean & Prep
# Clean & Prep - Löscht alte Artefakte und erstellt die Zielordner strukturiert
rm -rf app
rm -f .shack_session
mkdir -p app/server app/clients
@ -20,12 +20,13 @@ echo " -> [Client] Relais-Client..."
go build -o ./app/clients/client-relais ./cmd/client-relais/main.go
echo " -> [Client] Rotor-Client (Fyne GUI)..."
# CGO_ENABLED=1 ist zwingend erforderlich für Grafiktreiber-Kopplung von Fyne v2.7
CGO_ENABLED=1 go build -o ./app/clients/client-rotor ./cmd/client-rotor/main.go
echo " -> [Client] Volt-Monitor (ADS1115 Telemetrie)..."
go build -o ./app/clients/client-volt ./cmd/client-volt/main.go
# Ausführungsrechte vergeben
# Ausführungsrechte für alle erzeugten Binaries innerhalb der app-Struktur vergeben
find ./app -type f -exec chmod +x {} +
echo "✅ Alle Binaries erfolgreich in ./app/ erstellt."

View file

@ -1,18 +1,23 @@
// Package main implementiert das eigenständige Fyne-GUI-Fenster für die Funkgerätesteuerung (Rig Control).
// Es nutzt Fyne Data Binding für die UI und gRPC-Metadaten zur tokenbasierten Authentifizierung.
// Es simuliert das Erscheinungsbild moderner SDR-Software durch eine klickbare Ziffernanzeige (Plus/Minus
// Steuerung je Dekade), schnelle Betriebsartenumschaltung und dynamisch konfigurierbare Frequenzbänder.
package main
import (
"context"
"fmt"
"image/color"
"log"
"os"
"strconv"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/data/binding"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
@ -22,48 +27,47 @@ import (
"remote-shack/internal/config"
)
// RigClientGUI verwaltet alle UI-Komponenten und gRPC-Verbindungen des Funkgerätes.
type RigClientGUI struct {
// window repräsentiert das Fyne OS-Fenster.
window fyne.Window
// grpcClient hält die generierte Netzwerkschnittstelle zum Haupt-Server.
grpcClient shack.RigServiceClient
// opStream hält den aktiven, bidirektionalen Stream bei Operator-Rechten.
opStream shack.RigService_OperatorSessionClient
// streamCtx steuert den Lebenszyklus des Operator-Streams.
streamCtx context.Context
// streamCancel bricht den Stream bei einer Abmeldung ab.
streamCancel context.CancelFunc
// freqBind kapselt die Frequenzdaten thread-sicher für die UI.
freqBind binding.String
// modeBind kapselt Betriebsart und S-Meter für die UI.
modeBind binding.String
// opBind steuert das Textfeld bezüglich des aktuellen Operator-Namens.
opBind binding.String
actionBtn *widget.Button
upBtn *widget.Button
downBtn *widget.Button
currentFreq int64
currentMode string
isOperator bool
stationName string
sessionToken string // Speichert das vom AuthService erhaltene Token
// LocalBandConfig definiert die Struktur für ein dynamisch ladbares Frequenzband.
// Ermöglicht die flexible Definition beliebig vieler Bänder auf Client-Ebene.
type LocalBandConfig struct {
Name string
Frequency int64
}
// RigClientGUI verwaltet alle visuellen SDR-UI-Komponenten, die Datenbindungen
// sowie die bidirektionalen gRPC-Netzwerkstreams zum remote-shack Server.
type RigClientGUI struct {
window fyne.Window
grpcClient shack.RigServiceClient
opStream shack.RigService_OperatorSessionClient
streamCtx context.Context
streamCancel context.CancelFunc
freqBind binding.String
modeBind binding.String
opBind binding.String
currentFreq int64
currentMode string
isOperator bool
stationName string
sessionToken string
actionBtn *widget.Button
digitLabels []*widget.Label
configBands []LocalBandConfig
}
// main initialisiert die Laufzeitumgebung, authentifiziert den Client am AuthService,
// richtet die gRPC-Abfangjäger ein und startet das Fyne-Fenster.
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("=== Starte Remote-Shack Rig-Client ===")
log.Println("=== Starte Remote-Shack SDR-Rig-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("Fehler beim Laden der client-config.toml: %v", err)
}
// 1. Temporäre gRPC-Verbindung für den AuthService-Login aufbauen
tempConn, err := grpc.Dial(cfg.Client.ServerAddress, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("Konnte keine Verbindung für Login aufbauen: %v", err)
@ -81,16 +85,13 @@ func main() {
token := loginRes.Token
log.Printf("[Auth] Login erfolgreich. Sitzungstoken erhalten: %s", token)
// KORREKTUR: Beim Starten wird die Datei explizit gelöscht, da noch keine Steuerung aktiv ist!
_ = os.Remove(".shack_session")
// Interceptor injiziert das dynamische Session-Token automatisch in gRPC Unary-Aufrufe
unaryInterceptor := func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
ctx = metadata.AppendToOutgoingContext(ctx, "operator-token", token)
return invoker(ctx, method, req, reply, cc, opts...)
}
// Interceptor injiziert das dynamische Session-Token automatisch in gRPC Stream-Aufrufe
streamInterceptor := func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
ctx = metadata.AppendToOutgoingContext(ctx, "operator-token", token)
return streamer(ctx, desc, cc, method, opts...)
@ -106,56 +107,156 @@ func main() {
}
defer conn.Close()
grpcClient := shack.NewRigServiceClient(conn)
myApp := app.NewWithID("de.remote-shack.client.rig")
myWindow := myApp.NewWindow("Remote-Shack: Rig-Control")
sdrBands := []LocalBandConfig{
{Name: "160m", Frequency: 1840000},
{Name: "80m", Frequency: 3600000},
{Name: "40m", Frequency: 7074000},
{Name: "20m", Frequency: 14074000},
{Name: "15m", Frequency: 21074000},
{Name: "10m", Frequency: 28074000},
{Name: "2m", Frequency: 14550000},
{Name: "70cm", Frequency: 433500000},
}
gui := &RigClientGUI{
window: myWindow,
grpcClient: grpcClient,
grpcClient: shack.NewRigServiceClient(conn),
stationName: cfg.Client.StationName,
sessionToken: token,
freqBind: binding.NewString(),
modeBind: binding.NewString(),
opBind: binding.NewString(),
currentFreq: 14074000,
currentMode: "USB",
configBands: sdrBands,
}
_ = gui.freqBind.Set("00.000.000 MHz")
_ = gui.modeBind.Set("Betriebsart: --")
_ = gui.opBind.Set("Status: Verbinde...")
myApp := app.NewWithID("de.remote-shack.client.rig")
gui.window = myApp.NewWindow("Remote-Shack: SDR Rig-Control")
gui.buildUI()
go gui.startStatusMonitorLoop()
myWindow.Resize(fyne.NewSize(350, 380))
myWindow.ShowAndRun()
gui.window.Resize(fyne.NewSize(750, 480))
gui.window.ShowAndRun()
}
// buildUI erzeugt alle visuellen Komponenten und verknüpft sie mit den Data-Bindings.
// buildUI erzeugt das moderne SDR-Erscheinungsbild mit dekadischen Frequenz-Tasten,
// großen Ziffern, fixierten Tausender-Punkten und dynamisch generierten Band-Schaltflächen.
func (g *RigClientGUI) buildUI() {
freqLabel := widget.NewLabelWithData(g.freqBind)
freqLabel.Alignment = fyne.TextAlignCenter
freqLabel.TextStyle = fyne.TextStyle{Bold: true}
// Dekaden-Schritte für die 9 Ziffern (von 100 MHz bis 1 Hz)
decades := []int64{100000000, 10000000, 1000000, 100000, 10000, 1000, 100, 10, 1}
g.digitLabels = make([]*widget.Label, 9)
digitTexts := make([]*canvas.Text, 9)
// Kontrastfarbe definieren (Sattes Dunkelgrau/Schwarz für das helle Theme)
vfoColor := color.RGBA{R: 30, G: 30, B: 30, A: 255}
// Erzeugt ein Segment mit Pfeiltasten und einer sichtbaren Riesenziffer
createDigitSegment := func(idx int) fyne.CanvasObject {
step := decades[idx]
btnPlus := widget.NewButtonWithIcon("", theme.MoveUpIcon(), func() {
g.changeFrequency(step)
})
// KORREKTUR: Feste vfoColor statt theme.ForegroundColor() gegen den Weiß-auf-Weiß-Effekt
txtDigit := canvas.NewText("0", vfoColor)
txtDigit.TextSize = 36
txtDigit.TextStyle = fyne.TextStyle{Bold: true, Monospace: true}
txtDigit.Alignment = fyne.TextAlignCenter
digitTexts[idx] = txtDigit
lblDigit := widget.NewLabel("")
g.digitLabels[idx] = lblDigit
btnMinus := widget.NewButtonWithIcon("", theme.MoveDownIcon(), func() {
g.changeFrequency(-step)
})
return container.NewVBox(btnPlus, container.NewCenter(txtDigit), btnMinus)
}
// Baue die drei Blöcke
mhzBox := container.NewGridWithColumns(3, createDigitSegment(0), createDigitSegment(1), createDigitSegment(2))
khzBox := container.NewGridWithColumns(3, createDigitSegment(3), createDigitSegment(4), createDigitSegment(5))
hzBox := container.NewGridWithColumns(3, createDigitSegment(6), createDigitSegment(7), createDigitSegment(8))
// KORREKTUR: Punkte ebenfalls in vfoColor einfärben
dot1 := canvas.NewText(".", vfoColor)
dot1.TextSize = 36
dot1.TextStyle = fyne.TextStyle{Bold: true}
dot2 := canvas.NewText(".", vfoColor)
dot2.TextSize = 36
dot2.TextStyle = fyne.TextStyle{Bold: true}
// Perfekte horizontale Anordnung
vfoRow := container.NewHBox(
mhzBox,
container.NewCenter(dot1),
khzBox,
container.NewCenter(dot2),
hzBox,
)
sdrVFO := container.NewVBox(
widget.NewLabelWithStyle("--- SDR FREQUENZ OPTIONEN (KLICKBAR) ---", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
container.NewCenter(vfoRow),
)
// KORREKTUR: Der Listener füttert jetzt direkt die canvas.Text-Objekte
g.freqBind.AddListener(binding.NewDataListener(func() {
freqStr := fmt.Sprintf("%09d", g.currentFreq)
for idx, txt := range digitTexts {
if txt != nil && idx < len(freqStr) {
txt.Text = string(freqStr[idx])
txt.Refresh()
}
}
}))
// 2. Schnelle Betriebsarten-Umschaltung (Modes)
modes := []string{"LSB", "USB", "CW", "AM", "FM", "FT8"}
modeRow := container.NewGridWithColumns(len(modes))
for _, m := range modes {
modeName := m
btnMode := widget.NewButton(modeName, func() {
g.setOperatingMode(modeName)
})
modeRow.Add(btnMode)
}
// 3. Dynamische Frequenzbereiche (Bands) aus der Konfiguration geladen
bandGrid := container.NewGridWithColumns(4)
for _, b := range g.configBands {
bandData := b
btnBand := widget.NewButton(bandData.Name, func() {
g.setDirectFrequency(bandData.Frequency)
})
bandGrid.Add(btnBand)
}
// 4. Systemkomponenten und Operator-Verwaltung aus der Vorlage
modeLabel := widget.NewLabelWithData(g.modeBind)
modeLabel.Alignment = fyne.TextAlignCenter
opLabel := widget.NewLabelWithData(g.opBind)
opLabel.TextStyle = fyne.TextStyle{Italic: true}
g.upBtn = widget.NewButton("▲ +1 kHz", func() { g.changeFrequency(1000) })
g.downBtn = widget.NewButton("▼ -1 kHz", func() { g.changeFrequency(-1000) })
g.actionBtn = widget.NewButton("Steuerung übernehmen", func() { g.handleOperatorToggle() })
g.upBtn.Disable()
g.downBtn.Disable()
g.actionBtn = widget.NewButton("Steuerung übernehmen", func() {
g.handleOperatorToggle()
})
content := container.NewVBox(
widget.NewLabelWithStyle("--- FREQUENZANZEIGE ---", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
freqLabel,
sdrVFO,
widget.NewSeparator(),
widget.NewLabelWithStyle("--- BETRIEBSARTEN ---", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
modeRow,
modeLabel,
container.NewGridWithColumns(2, g.upBtn, g.downBtn),
widget.NewSeparator(),
widget.NewLabelWithStyle("--- FREQUENZBEREICHE (DYNAMISCH) ---", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
bandGrid,
widget.NewSeparator(),
opLabel,
g.actionBtn,
@ -163,6 +264,51 @@ func (g *RigClientGUI) buildUI() {
g.window.SetContent(content)
}
// setDirectFrequency springt ohne Umwege direkt auf eine absolute Frequenz.
func (g *RigClientGUI) setDirectFrequency(freqHz int64) {
if !g.isOperator || g.opStream == nil {
return
}
g.currentFreq = freqHz
_ = g.freqBind.Set(strconv.FormatInt(freqHz, 10))
g.transmitRigCommand()
}
// setOperatingMode schaltet die Demodulation (z.B. USB, LSB, CW) des Funkgeräts um.
func (g *RigClientGUI) setOperatingMode(mode string) {
if !g.isOperator || g.opStream == nil {
return
}
g.currentMode = mode
g.transmitRigCommand()
}
// changeFrequency addiert oder subtrahiert relative Frequenzschritte (Delta)
// basierend auf den gedrückten SDR-Dekadentasten.
func (g *RigClientGUI) changeFrequency(deltaHz int64) {
if !g.isOperator || g.opStream == nil {
return
}
g.currentFreq += deltaHz
if g.currentFreq < 0 {
g.currentFreq = 0
}
_ = g.freqBind.Set(strconv.FormatInt(g.currentFreq, 10))
g.transmitRigCommand()
}
// transmitRigCommand kapselt das Absenden der veränderten VFO-Parameter
// über den hocheffizienten, bidirektionalen gRPC-Operatorstream.
func (g *RigClientGUI) transmitRigCommand() {
if g.opStream == nil {
return
}
_ = g.opStream.Send(&shack.RigCommand{
FrequencyHz: g.currentFreq,
Mode: g.currentMode,
})
}
// startStatusMonitorLoop liest den Server-Status im Hintergrund und stößt UI-Updates via fyne.Do an.
func (g *RigClientGUI) startStatusMonitorLoop() {
for {
@ -184,19 +330,19 @@ func (g *RigClientGUI) startStatusMonitorLoop() {
break
}
g.currentFreq = status.FrequencyHz
g.currentMode = status.Mode
if !g.isOperator {
g.currentFreq = status.FrequencyHz
g.currentMode = status.Mode
_ = g.freqBind.Set(strconv.FormatInt(g.currentFreq, 10))
}
_ = g.freqBind.Set(fmt.Sprintf("%.3f MHz", float64(g.currentFreq)/1000000.0))
_ = g.modeBind.Set(fmt.Sprintf("Betriebsart: %s | S-Meter: S%d", g.currentMode, status.SMeter))
_ = g.modeBind.Set(fmt.Sprintf("Betriebsart: %s | S-Meter: S%d", status.Mode, status.SMeter))
fyne.Do(func() {
if g.isOperator {
_ = g.opBind.Set("Status: Sie sind der aktive OPERATOR")
g.actionBtn.SetText("Abmelden / Freigeben")
g.actionBtn.Enable()
g.upBtn.Enable()
g.downBtn.Enable()
return
}
@ -204,21 +350,15 @@ func (g *RigClientGUI) startStatusMonitorLoop() {
_ = g.opBind.Set("Status: Steuerung ist FREI")
g.actionBtn.SetText("Steuerung übernehmen")
g.actionBtn.Enable()
g.upBtn.Disable()
g.downBtn.Disable()
} else {
if status.ActiveOperatorName == g.stationName {
_ = g.opBind.Set("Status: Sie sind der aktive OPERATOR")
g.actionBtn.SetText("Abmelden / Freigeben")
g.actionBtn.Enable()
g.upBtn.Enable()
g.downBtn.Enable()
} else {
_ = g.opBind.Set(fmt.Sprintf("Operator: %s", status.ActiveOperatorName))
g.actionBtn.SetText("Steuerung blockiert")
g.actionBtn.Disable()
g.upBtn.Disable()
g.downBtn.Disable()
}
}
})
@ -227,18 +367,7 @@ func (g *RigClientGUI) startStatusMonitorLoop() {
}
}
// changeFrequency übermittelt Abstimmbefehle via hocheffizientem gRPC-Streaming.
func (g *RigClientGUI) changeFrequency(deltaHz int64) {
if !g.isOperator || g.opStream == nil {
return
}
_ = g.opStream.Send(&shack.RigCommand{
FrequencyHz: g.currentFreq + deltaHz,
Mode: g.currentMode,
})
}
// handleOperatorToggle öffnet oder schließt die OperatorSession (Änderungen via fyne.Do gekapselt).
// handleOperatorToggle öffnet oder schließt die OperatorSession.
func (g *RigClientGUI) handleOperatorToggle() {
if !g.isOperator {
g.streamCtx, g.streamCancel = context.WithCancel(context.Background())
@ -251,13 +380,10 @@ func (g *RigClientGUI) handleOperatorToggle() {
g.opStream = stream
g.isOperator = true
// KORREKTUR: Datei wird erst JETZT bei echtem Tastendruck geschrieben!
_ = os.WriteFile(".shack_session", []byte(g.sessionToken), 0600)
fyne.Do(func() {
g.actionBtn.SetText("Abmelden / Freigeben")
g.upBtn.Enable()
g.downBtn.Enable()
})
_ = g.opStream.Send(&shack.RigCommand{})
@ -296,6 +422,4 @@ func (g *RigClientGUI) resetOperatorState() {
_ = os.Remove(".shack_session")
g.actionBtn.SetText("Steuerung übernehmen")
g.actionBtn.Enable()
g.upBtn.Disable()
g.downBtn.Disable()
}

View file

@ -27,14 +27,17 @@ import (
"remote-shack/internal/config"
)
// FixedSizeLayout erzwingt eine feste Grafikgröße für die Kompass-Container.
type FixedSizeLayout struct {
size fyne.Size
}
// NewFixedSizeLayout initialisiert das Layout mit einer fixen Zielgröße.
func NewFixedSizeLayout(size fyne.Size) fyne.Layout {
return &FixedSizeLayout{size: size}
}
// Layout positioniert die Steuerungselemente innerhalb des fixen Rasters.
func (f *FixedSizeLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
for _, obj := range objects {
if _, ok := obj.(*widget.Button); !ok {
@ -44,10 +47,12 @@ func (f *FixedSizeLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
}
}
// MinSize gibt die feste Mindestgröße zurück.
func (f *FixedSizeLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
return f.size
}
// RotorClientGUI hält den gesamten Zustand der Rotor-Oberfläche und der Netzwerk-Schnittstelle.
type RotorClientGUI struct {
window fyne.Window
grpcClient shack.RotorServiceClient
@ -67,6 +72,7 @@ type RotorClientGUI struct {
targetElevation float64
}
// main lädt die Konfiguration, prüft die lokale Sitzung und baut die gRPC-Schnittstelle auf.
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("=== Starte Remote-Shack Sat-Rotor-Client ===")
@ -120,7 +126,8 @@ func main() {
go gui.startLivePositionLoop()
gui.window.ShowAndRun()
}
// buildUI erzeugt alle visuellen Komponenten, verknüpft sie mit den Data-Bindings
// und positioniert die bogenförmigen Elevations-Schaltflächen (inklusive des neuen -15° Buttons).
func (g *RotorClientGUI) buildUI() {
redColor := color.RGBA{R: 230, G: 30, B: 30, A: 255}
greenColor := color.RGBA{R: 30, G: 200, B: 30, A: 255}
@ -141,8 +148,29 @@ func (g *RotorClientGUI) buildUI() {
azCircle.FillColor = color.Transparent
azCircle.Resize(fyne.NewSize(210, 210))
azCircle.Move(fyne.NewPos(45, 45))
// Himmelsrichtungs-Textmarker per canvas.NewText (Radius ~120 um Center 150,150)
txtN := canvas.NewText("N", theme.ForegroundColor())
txtN.Alignment = fyne.TextAlignCenter
txtN.TextStyle = fyne.TextStyle{Bold: true}
txtN.Move(fyne.NewPos(140, 12))
txtS := canvas.NewText("S", theme.ForegroundColor())
txtS.Alignment = fyne.TextAlignCenter
txtS.TextStyle = fyne.TextStyle{Bold: true}
txtS.Move(fyne.NewPos(140, 268))
txtO := canvas.NewText("O", theme.ForegroundColor())
txtO.Alignment = fyne.TextAlignCenter
txtO.TextStyle = fyne.TextStyle{Bold: true}
txtO.Move(fyne.NewPos(268, 140))
txtW := canvas.NewText("W", theme.ForegroundColor())
txtW.Alignment = fyne.TextAlignCenter
txtW.TextStyle = fyne.TextStyle{Bold: true}
txtW.Move(fyne.NewPos(12, 140))
azDisplay := container.NewWithoutLayout(azCircle, g.azTargetNeedle, g.azNeedle)
azDisplay := container.NewWithoutLayout(azCircle, g.azTargetNeedle, g.azNeedle, txtN, txtS, txtO, txtW)
azDisplay.Resize(fyne.NewSize(300, 300))
// 2. Tasten für den äußeren Windrosen-Ring (Radius 128 Pixel vom Center 150,150)
@ -169,29 +197,30 @@ func (g *RotorClientGUI) buildUI() {
)
windroseContainer := container.New(NewFixedSizeLayout(fyne.NewSize(300, 300)), rawWindrose)
// 3. Elevation-Ausschnitt (Center links-unten bei 15,240, Grafikbox-Breite 240)
// 3. Elevation-Ausschnitt (Zentrum auf 15,200 angehoben für negative Werte)
g.elNeedle = canvas.NewLine(redColor)
g.elNeedle.StrokeWidth = 4
g.elNeedle.Position1 = fyne.NewPos(15, 240)
g.elNeedle.Position2 = fyne.NewPos(235, 240)
g.elNeedle.Position1 = fyne.NewPos(15, 200)
g.elNeedle.Position2 = fyne.NewPos(235, 200)
g.elTargetNeedle = canvas.NewLine(greenColor)
g.elTargetNeedle.StrokeWidth = 3
g.elTargetNeedle.Position1 = fyne.NewPos(15, 240)
g.elTargetNeedle.Position2 = fyne.NewPos(235, 240)
g.elTargetNeedle.Position1 = fyne.NewPos(15, 200)
g.elTargetNeedle.Position2 = fyne.NewPos(235, 200)
elArc := canvas.NewCircle(theme.ForegroundColor())
elArc.StrokeWidth = 2
elArc.FillColor = color.Transparent
elArc.Resize(fyne.NewSize(440, 440))
elArc.Move(fyne.NewPos(-205, 20))
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)
horizonLine.Position1 = fyne.NewPos(15, 200)
horizonLine.Position2 = fyne.NewPos(235, 200)
// Bogenförmige Elevations-Tasten (Radius 240 Px vom Ursprung 15,240 nach rechts oben)
// Bogenförmige Tasten
btnElM15 := widget.NewButton("-15°", func() { g.executeDirectMove(g.targetAzimuth, -15) })
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) })
@ -199,16 +228,17 @@ func (g *RotorClientGUI) buildUI() {
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))
btnElM15.Move(fyne.NewPos(225, 240)); btnElM15.Resize(fyne.NewSize(56, 32))
btnEl0.Move(fyne.NewPos(242, 184)); btnEl0.Resize(fyne.NewSize(56, 32))
btnEl15.Move(fyne.NewPos(232, 122)); btnEl15.Resize(fyne.NewSize(56, 32))
btnEl30.Move(fyne.NewPos(206, 65)); btnEl30.Resize(fyne.NewSize(56, 32))
btnEl45.Move(fyne.NewPos(166, 18)); btnEl45.Resize(fyne.NewSize(56, 32))
btnEl60.Move(fyne.NewPos(115, -18)); btnEl60.Resize(fyne.NewSize(56, 32))
btnEl90.Move(fyne.NewPos(2, -42)); btnEl90.Resize(fyne.NewSize(56, 32))
elDisplay := container.NewWithoutLayout(
elArc, horizonLine, g.elTargetNeedle, g.elNeedle,
btnEl0, btnEl15, btnEl30, btnEl45, btnEl60, btnEl90,
btnElM15, btnEl0, btnEl15, btnEl30, btnEl45, btnEl60, btnEl90,
)
elDisplay.Resize(fyne.NewSize(300, 300))
@ -222,7 +252,7 @@ func (g *RotorClientGUI) buildUI() {
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)),
container.NewVBox(widget.NewLabelWithStyle("ELEVATION (-20° bis 90°)", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}), container.NewCenter(elDisplay), widget.NewLabelWithData(g.elBind)),
)
content := container.NewVBox(
@ -238,9 +268,9 @@ func (g *RotorClientGUI) buildUI() {
g.window.SetContent(container.NewMax(content))
}
// startLivePositionLoop lauscht auf den gRPC-Stream und zeichnet Ist-Zeiger live nach (angepasster Offset)
func (g *RotorClientGUI) startLivePositionLoop() {
firstSync := true // Schützt den initialen RAM-Zustand beim Einschalten
firstSync := true
for {
stream, err := g.grpcClient.LivePosition(context.Background(), &shack.Empty{})
if err != nil {
@ -250,11 +280,10 @@ func (g *RotorClientGUI) startLivePositionLoop() {
for {
status, err := stream.Recv()
if err != nil { break }
_ = 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
@ -272,22 +301,25 @@ func (g *RotorClientGUI) startLivePositionLoop() {
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)
// Elevation Ist-Vektor (Center 15,200, Radius 220 Px - Invertiert für korrekte Drehung nach unten)
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.Position2 = fyne.NewPos(float32(15.0+220.0*math.Cos(radEl)), float32(200.0+220.0*math.Sin(radEl)))
g.elNeedle.Refresh()
})
}
}
}
// sendManualPosition liest die Eingabefelder aus und übergibt die Daten an die Move-Logik
func (g *RotorClientGUI) sendManualPosition() {
az, _ := strconv.ParseFloat(g.azInput.Text, 64)
el, _ := strconv.ParseFloat(g.elInput.Text, 64)
g.executeDirectMove(az, el)
}
// executeDirectMove führt deine originalgetreue Rechte-Validierung durch und sendet per gRPC TargetPosition
func (g *RotorClientGUI) executeDirectMove(az float64, el float64) {
// EXAKTE WIEDERHERSTELLUNG: Deine lückenlose Dateiprüfung für die Operator-Rechte
tokenBytes, err := os.ReadFile(".shack_session")
if err != nil || len(tokenBytes) == 0 {
fyne.Do(func() { g.statusLabel.SetText("⚠️ Blockiert: Keine Operator-Rechte!") })
@ -295,7 +327,10 @@ func (g *RotorClientGUI) executeDirectMove(az float64, el float64) {
}
g.token = string(tokenBytes)
// FIX: Die Variablen werden jetzt absolut lückenlos im RAM festgehalten
// Schutzgrenzen angepasst für Troposcatter / negative Werte
if az < 0 { az = 0 }; if az > 360 { az = 360 }
if el < -20 { el = -20 }; if el > 90 { el = 90 }
g.targetAzimuth = az
g.targetElevation = el
@ -305,12 +340,13 @@ func (g *RotorClientGUI) executeDirectMove(az float64, el float64) {
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)
// Elevation Soll-Vektor (Center 15,200)
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.Position2 = fyne.NewPos(float32(15.0+220.0*math.Cos(radElTar)), float32(200.0+220.0*math.Sin(radElTar)))
g.elTargetNeedle.Refresh()
})
// EXAKTE WIEDERHERSTELLUNG: Deine echte gRPC-Netzwerk-Übertragung
ctx := metadata.AppendToOutgoingContext(context.Background(), "operator-token", g.token)
_, err = g.grpcClient.TargetPosition(ctx, &shack.RotorTarget{Azimuth: az, Elevation: el})
if err != nil {
@ -319,3 +355,4 @@ func (g *RotorClientGUI) executeDirectMove(az float64, el float64) {
fyne.Do(func() { g.statusLabel.SetText(fmt.Sprintf("Fahre Position an: AZ %.1f° | EL %.1f°", az, el)) })
}
}