remote-shack/cmd/client-rig/main.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()
}