427 lines
13 KiB
Go
427 lines
13 KiB
Go
// Package main implementiert das eigenständige Fyne-GUI-Fenster für die Funkgerätesteuerung (Rig Control).
|
|
// 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"
|
|
"google.golang.org/grpc/metadata"
|
|
|
|
shack "remote-shack/api"
|
|
"remote-shack/internal/config"
|
|
)
|
|
|
|
// 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 SDR-Rig-Client ===")
|
|
|
|
cfg, err := config.LoadClientConfig("client-config.toml")
|
|
if err != nil {
|
|
log.Fatalf("Fehler beim Laden der client-config.toml: %v", err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
authClient := shack.NewAuthServiceClient(tempConn)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
loginRes, err := authClient.Login(ctx, &shack.LoginRequest{Username: cfg.Client.StationName})
|
|
cancel()
|
|
tempConn.Close()
|
|
|
|
if err != nil {
|
|
log.Fatalf("Login am gRPC-AuthService fehlgeschlagen: %v", err)
|
|
}
|
|
token := loginRes.Token
|
|
log.Printf("[Auth] Login erfolgreich. Sitzungstoken erhalten: %s", token)
|
|
|
|
_ = os.Remove(".shack_session")
|
|
|
|
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...)
|
|
}
|
|
|
|
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...)
|
|
}
|
|
|
|
conn, err := grpc.Dial(cfg.Client.ServerAddress,
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
grpc.WithUnaryInterceptor(unaryInterceptor),
|
|
grpc.WithStreamInterceptor(streamInterceptor),
|
|
)
|
|
if err != nil {
|
|
log.Fatalf("Konnte keine Hauptverbindung zum Server aufbauen: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
|
|
// 1. Konvertierung der TOML-Bänder in die interne GUI-Struktur.
|
|
// Wir lesen die geladene Liste dynamisch aus cfg.Client.Bands aus.
|
|
var sdrBands []LocalBandConfig
|
|
for _, b := range cfg.Client.Bands {
|
|
sdrBands = append(sdrBands, LocalBandConfig{
|
|
Name: b.Name,
|
|
Frequency: b.Frequency,
|
|
})
|
|
}
|
|
log.Printf("[UI-Init] %d Frequenzbänder erfolgreich aus Konfiguration geladen.", len(sdrBands))
|
|
|
|
// 2. Instanziierung des GUI-Zustands mit den dynamischen Bändern
|
|
gui := &RigClientGUI{
|
|
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, // Die aus der TOML generierten Bänder werden hier übergeben
|
|
}
|
|
|
|
myApp := app.NewWithID("de.remote-shack.client.rig")
|
|
gui.window = myApp.NewWindow("Remote-Shack: SDR Rig-Control")
|
|
|
|
gui.buildUI()
|
|
go gui.startStatusMonitorLoop()
|
|
|
|
gui.window.Resize(fyne.NewSize(750, 480))
|
|
gui.window.ShowAndRun()
|
|
}
|
|
|
|
// 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() {
|
|
// 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.actionBtn = widget.NewButton("Steuerung übernehmen", func() {
|
|
g.handleOperatorToggle()
|
|
})
|
|
|
|
content := container.NewVBox(
|
|
sdrVFO,
|
|
widget.NewSeparator(),
|
|
widget.NewLabelWithStyle("--- BETRIEBSARTEN ---", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
|
modeRow,
|
|
modeLabel,
|
|
widget.NewSeparator(),
|
|
widget.NewLabelWithStyle("--- FREQUENZBEREICHE (DYNAMISCH) ---", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
|
bandGrid,
|
|
widget.NewSeparator(),
|
|
opLabel,
|
|
g.actionBtn,
|
|
)
|
|
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 {
|
|
stream, err := g.grpcClient.MonitorSession(context.Background(), &shack.Empty{})
|
|
if err != nil {
|
|
_ = g.opBind.Set("Server nicht erreichbar. Reconnect...")
|
|
_ = os.Remove(".shack_session")
|
|
fyne.Do(func() {
|
|
g.actionBtn.SetText("Verbindung verloren")
|
|
g.actionBtn.Disable()
|
|
})
|
|
time.Sleep(2 * time.Second)
|
|
continue
|
|
}
|
|
|
|
for {
|
|
status, err := stream.Recv()
|
|
if err != nil {
|
|
break
|
|
}
|
|
|
|
if !g.isOperator {
|
|
g.currentFreq = status.FrequencyHz
|
|
g.currentMode = status.Mode
|
|
_ = g.freqBind.Set(strconv.FormatInt(g.currentFreq, 10))
|
|
}
|
|
|
|
_ = 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()
|
|
return
|
|
}
|
|
|
|
if status.ActiveOperatorName == "FREI" || status.ActiveOperatorName == "" {
|
|
_ = g.opBind.Set("Status: Steuerung ist FREI")
|
|
g.actionBtn.SetText("Steuerung übernehmen")
|
|
g.actionBtn.Enable()
|
|
} else {
|
|
if status.ActiveOperatorName == g.stationName {
|
|
_ = g.opBind.Set("Status: Sie sind der aktive OPERATOR")
|
|
g.actionBtn.SetText("Abmelden / Freigeben")
|
|
g.actionBtn.Enable()
|
|
} else {
|
|
_ = g.opBind.Set(fmt.Sprintf("Operator: %s", status.ActiveOperatorName))
|
|
g.actionBtn.SetText("Steuerung blockiert")
|
|
g.actionBtn.Disable()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
time.Sleep(1 * time.Second)
|
|
}
|
|
}
|
|
|
|
// handleOperatorToggle öffnet oder schließt die OperatorSession.
|
|
func (g *RigClientGUI) handleOperatorToggle() {
|
|
if !g.isOperator {
|
|
g.streamCtx, g.streamCancel = context.WithCancel(context.Background())
|
|
stream, err := g.grpcClient.OperatorSession(g.streamCtx)
|
|
if err != nil {
|
|
log.Printf("Fehler beim Öffnen der Operator Session: %v", err)
|
|
return
|
|
}
|
|
|
|
g.opStream = stream
|
|
g.isOperator = true
|
|
|
|
_ = os.WriteFile(".shack_session", []byte(g.sessionToken), 0600)
|
|
|
|
fyne.Do(func() {
|
|
g.actionBtn.SetText("Abmelden / Freigeben")
|
|
})
|
|
|
|
_ = g.opStream.Send(&shack.RigCommand{})
|
|
|
|
go func() {
|
|
for {
|
|
_, err := g.opStream.Recv()
|
|
if err != nil {
|
|
fyne.Do(func() {
|
|
g.resetOperatorState()
|
|
})
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
} else {
|
|
if g.opStream != nil {
|
|
_ = g.opStream.Send(&shack.RigCommand{
|
|
FrequencyHz: 0,
|
|
Mode: "DISCONNECT",
|
|
})
|
|
}
|
|
fyne.Do(func() {
|
|
g.resetOperatorState()
|
|
})
|
|
}
|
|
}
|
|
|
|
// resetOperatorState überführt die Widgets wieder in den passiven Zuhörermodus.
|
|
func (g *RigClientGUI) resetOperatorState() {
|
|
if g.streamCancel != nil {
|
|
g.streamCancel()
|
|
}
|
|
g.opStream = nil
|
|
g.isOperator = false
|
|
_ = os.Remove(".shack_session")
|
|
g.actionBtn.SetText("Steuerung übernehmen")
|
|
g.actionBtn.Enable()
|
|
}
|