diff --git a/Notizen.txt b/Notizen.txt index 5eebb7c..9bf5997 100644 --- a/Notizen.txt +++ b/Notizen.txt @@ -12,7 +12,7 @@ Funktionsprinzip: +-------------------+ +-----------------------+ | (Listener: View only + RX) | +---------------------------------+ -mkdir -p remote-shack/{api,cmd/{server,client-rig,client-rotor,client-relais},internal/{config,driver,audio}} +mkdir -p remote-shack/{api,cmd/{server,client-rig,client-rotor,client-relais,test-rig},internal/{config,driver,audio}} remote-shack/ ├── api/ # Protobuf-Definitionen für gRPC (.proto) @@ -81,5 +81,46 @@ go mod tidy weitermachen: -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. +============================================================================== +PROJEKT-MERKER: REMOTE-SHACK START AM MORGEN +============================================================================== + +AKTUELLER STATUS: +----------------- +* SDR-Rig-Client: Vollständig auf ein modernes SDR-Design umgestellt. + Die Frequenzanzeige ist massiv vergrößert (canvas.Text, Größe 36, Monospace) + und durch unverschiebbare Tausender-Punkte (014.250.000) in MHz-, kHz- + und Hz-Blöcke strukturiert. Die Bandtasten werden dynamisch geladen. + +* Sat-Rotor-Client: Geometrisch entkoppelt im 300x300-Format. Elevation + wurde für Erdverbindungen/Troposcatter auf -20° bis 90° erweitert. Der + Kreismittelpunkt wurde auf Y=200 angehoben, um Platz nach unten zu + schaffen. Ein neuer -15°-Bogen-Button ist integriert. Die originale + tokenbasierte gRPC-Schreib-Validierung via TargetPosition ist lückenlos aktiv. + +* Kompilierung: Das build.sh-Skript ist repariert und berücksichtigt + CGO_ENABLED=1. Alle Komponenten bauen fehlerfrei direkt in die + Zielordner (./app/server/ und ./app/clients/). + +* Architektur-Entscheidung: Wechsel auf ein flexibles Connector-Modell + (Abstraktionsschicht analog zu Hamlib). Der gRPC-Server spricht nur noch + mit generischen Interfaces. Die Hardware (ProSistel, SPID etc.) wird rein + über die TOML-Konfigurationsdateien eingekoppelt, ohne den Core-Code zu verändern. + +NÄCHSTE SCHRITTE: +----------------- +1. Implementierung des konkreten ProSistel-Seriell-Protokolls im neuen + Connector unter internal/driver/. +2. Vorbereitung des SPID-Rotor-Connectors für das Gerät des Bekannten. +3. Einbindung des ADS1115-I2C-Treibers im Server für die reine Shack- + Betriebsspannungs-Telemetrie (Entkoppelt von der Rotor-Grad-Messung). + +BEREITZUSTELLENDE DATEIEN FÜR DEN START: +---------------------------------------- +Bitte lade zu Beginn des neuen Chats diese Dateien hoch, um die Code-Basis +direkt abzugleichen: +1. internal/driver/interfaces.go (Das aktuelle Interface-Gerüst) +2. server-config.toml & client-config.toml (Für die neuen Hardware-Felder) +3. Technische Infos zum ProSistel-Protokoll (Befehlssatz für Az/El über Seriell) +============================================================================== diff --git a/app/clients/client-relais b/app/clients/client-relais index 9563c9d..5e89d2c 100755 Binary files a/app/clients/client-relais and b/app/clients/client-relais differ diff --git a/app/clients/client-rig b/app/clients/client-rig index 5e46c87..be37e1a 100755 Binary files a/app/clients/client-rig and b/app/clients/client-rig differ diff --git a/app/clients/client-rotor b/app/clients/client-rotor index b6e20a4..c3a93b0 100755 Binary files a/app/clients/client-rotor and b/app/clients/client-rotor differ diff --git a/app/clients/client-volt b/app/clients/client-volt index cda3736..7d26b79 100755 Binary files a/app/clients/client-volt and b/app/clients/client-volt differ diff --git a/app/server/shack-server b/app/server/shack-server index 25e5df1..0a96bad 100755 Binary files a/app/server/shack-server and b/app/server/shack-server differ diff --git a/cmd/server/main.go b/cmd/server/main.go index f1934d8..091bcd8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -31,7 +31,7 @@ type ShackServer struct { relais driver.RelaisDriver mu sync.Mutex - activeOperator string + activeOperator string // Enthält das Token des aktuellen Operators tokenToName map[string]string } @@ -45,13 +45,52 @@ func main() { cfg = &config.ServerConfig{Server: config.ServerSettings{ListenAddress: "0.0.0.0:50051"}} } + // 1. Rig-Treiber anhand der TOML initialisieren + var rigHardware driver.RigDriver + if cfg.Server.HardwareRig.Type == "FT897" { + log.Printf("[Hardware] Initialisiere Yaesu FT-897 an %s mit %d Baud...", cfg.Server.HardwareRig.Port, cfg.Server.HardwareRig.Baud) + ft897 := driver.NewFT897Driver() + if err := ft897.Connect(cfg.Server.HardwareRig.Port, cfg.Server.HardwareRig.Baud); err != nil { + log.Printf("[CRITICAL] Fehler beim Connect zum FT-897: %v. Nutze Dummy-Rig als Fallback!", err) + rigHardware = driver.NewDummyRig() + } else { + rigHardware = ft897 + } + } else { + log.Println("[Hardware] Kein Rig-Typ definiert. Verwende Dummy-Rig.") + rigHardware = driver.NewDummyRig() + } + + // 2. Rotor-Treiber anhand der TOML initialisieren + var rotorHardware driver.RotorDriver + if cfg.Server.HardwareRotor.Type == "ProSistel" { + log.Printf("[Hardware] Initialisiere ProSistel Rotor an %s mit %d Baud...", cfg.Server.HardwareRotor.Port, cfg.Server.HardwareRotor.Baud) + prosistel := driver.NewProSistelDriver() + if err := prosistel.Connect(cfg.Server.HardwareRotor.Port, cfg.Server.HardwareRotor.Baud); err != nil { + log.Printf("[CRITICAL] Fehler beim Connect zum ProSistel-Rotor: %v. Nutze Dummy-Rotor als Fallback!", err) + rotorHardware = driver.NewDummyRotor() + } else { + rotorHardware = prosistel + } + } else { + log.Println("[Hardware] Kein Rotor-Typ definiert. Verwende Dummy-Rotor.") + rotorHardware = driver.NewDummyRotor() + } + server := &ShackServer{ - rig: driver.NewDummyRig(), - rotor: driver.NewDummyRotor(), - relais: driver.NewDummyRelais(), + rig: rigHardware, + rotor: rotorHardware, + relais: driver.NewDummyRelais(), // Später I2C ADS1115 / Relais-Board tokenToName: make(map[string]string), } + // Ressourcen-Freigabe beim Beenden sichern + defer func() { + log.Println("[Hardware] Schließe Schnittstellen...") + _ = server.rig.Disconnect() + _ = server.rotor.Disconnect() + }() + lis, err := net.Listen("tcp", cfg.Server.ListenAddress) if err != nil { log.Fatalf("Listen Fehler: %v", err) @@ -108,7 +147,7 @@ func (s *ShackServer) checkAccess(token string) bool { if _, valid := s.tokenToName[token]; !valid { return false } if s.activeOperator == "" { s.activeOperator = token - log.Printf("[Rechte] %s ist nun aktiver Operator.", s.tokenToName[token]) + log.Printf("[Rechte] %s (%s) ist nun aktiver Operator.", s.tokenToName[token], token) } return s.activeOperator == token } @@ -120,16 +159,47 @@ func (s *ShackServer) OperatorSession(stream api.RigService_OperatorSessionServe return fmt.Errorf("zugriff verweigert: nicht aktiver operator") } + defer func() { + s.mu.Lock() + if s.activeOperator == token { + s.activeOperator = "" + } + s.mu.Unlock() + }() + + log.Printf("[Server] Operator-Stream erfolgreich etabliert. Warte auf Kommandos...") + for { cmd, err := stream.Recv() if err != nil { - s.mu.Lock() - if s.activeOperator == token { s.activeOperator = "" } - s.mu.Unlock() return err } - if cmd.FrequencyHz > 0 { _ = s.rig.SetFrequency(cmd.FrequencyHz) } - if cmd.Mode != "" { _ = s.rig.SetMode(cmd.Mode) } + + if cmd.Mode == "DISCONNECT" { + log.Println("[Server] Operator hat sich regulär abgemeldet.") + return nil + } + + // AUSFÜHRLICHES DIAGNOSE-LOG: + // Hier prüfen wir im Terminal, was gRPC tatsächlich aus dem Netzwerk auspackt! + log.Printf("[gRPC-In] Rohdaten empfangen -> Frequenz-Feld: %d, Modus-Feld: '%s', PTT: %v", + cmd.FrequencyHz, cmd.Mode, cmd.Ptt) + + // Hardware-Ansteuerung mit Absicherung + if cmd.FrequencyHz > 0 { + log.Printf("[Hardware-Befehl] Sende SetFrequency(%d Hz) an den Yaesu-Treiber...", cmd.FrequencyHz) + if err := s.rig.SetFrequency(cmd.FrequencyHz); err != nil { + log.Printf("[Hardware-FEHLER] Yaesu verweigert Frequenz: %v", err) + } + } + + if cmd.Mode != "" && cmd.Mode != "DISCONNECT" { + log.Printf("[Hardware-Befehl] Sende SetMode('%s') an den Yaesu-Treiber...", cmd.Mode) + if err := s.rig.SetMode(cmd.Mode); err != nil { + log.Printf("[Hardware-FEHLER] Yaesu verweigert Modus: %v", err) + } + } + _ = s.rig.SetPTT(cmd.Ptt) } } @@ -139,12 +209,28 @@ func (s *ShackServer) MonitorSession(req *api.Empty, stream api.RigService_Monit defer ticker.Stop() for { select { - case <-stream.Context().Done(): return nil + case <-stream.Context().Done(): + return nil case <-ticker.C: f, _ := s.rig.GetFrequency() m, _ := s.rig.GetMode() s_meter, _ := s.rig.GetSMeter() - _ = stream.Send(&api.RigStatus{FrequencyHz: f, Mode: m, SMeter: int32(s_meter)}) + + s.mu.Lock() + opToken := s.activeOperator + opName := "FREI" + if name, exists := s.tokenToName[opToken]; exists && opToken != "" { + opName = name + } + s.mu.Unlock() + + // Übertrage den verifizierten Betreibernamen an das SDR-Display des Clients + _ = stream.Send(&api.RigStatus{ + FrequencyHz: f, + Mode: m, + SMeter: int32(s_meter), + ActiveOperatorName: opName, + }) } } } diff --git a/cmd/test_rig/main.go b/cmd/test_rig/main.go new file mode 100644 index 0000000..6090682 --- /dev/null +++ b/cmd/test_rig/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "log" + "time" + + "remote-shack/internal/driver" +) + +func main() { + log.Println("=== Starte direkten Hardware-Test: Yaesu FT-897 ===") + + // 1. Treiber-Instanz erstellen + rig := driver.NewFT897Driver() + + // 2. Verbindung mit den neuen 38400 Baud aufbauen + port := "/dev/ttyUSB0" + baud := 38400 + log.Printf("Öffne Schnittstelle %s mit %d Baud...", port, baud) + + if err := rig.Connect(port, baud); err != nil { + log.Fatalf("VERBINDUNGSFEHLER: %v", err) + } + defer func() { + _ = rig.Disconnect() + log.Println("Schnittstelle sauber geschlossen.") + }() + + // 3. Aktuelle Frequenz und Mode auslesen (Ist-Zustand abfragen) + log.Println("Frage aktuellen Transceiver-Status ab...") + time.Sleep(200 * time.Millisecond) // Dem Controller kurz Zeit geben + + startFreq, err := rig.GetFrequency() + if err != nil { + log.Printf("Warnung: Konnte Frequenz nicht lesen: %v", err) + } else { + log.Printf("-> Aktuelle Frequenz am Gerät: %d Hz", startFreq) + } + + startMode, err := rig.GetMode() + if err != nil { + log.Printf("Warnung: Konnte Modus nicht lesen: %v", err) + } else { + log.Printf("-> Aktueller Modus am Gerät: %s", startMode) + } + + // 4. Test-Kommando: Frequenz auf das 20m-Band setzen (14.074.000 Hz) + targetFreq := int64(14074000) + log.Printf("Setze Test-Frequenz auf FT8-Frequenz: %d Hz...", targetFreq) + if err := rig.SetFrequency(targetFreq); err != nil { + log.Fatalf("Fehler beim Setzen der Frequenz: %v", err) + } + + // 5. Test-Kommando: Mode auf USB setzen + targetMode := "USB" + log.Printf("Schalte Betriebsart auf: %s...", targetMode) + if err := rig.SetMode(targetMode); err != nil { + log.Printf("Fehler beim Setzen des Modus: %v", err) + } + + // 6. S-Meter Live-Test (3 Sekunden lang abfragen) + log.Println("Starte 3-sekündige S-Meter Überwachung...") + for i := 0; i < 6; i++ { + smeter, err := rig.GetSMeter() + if err != nil { + log.Printf("S-Meter Fehler: %v", err) + } else { + log.Printf("[Live Status] S-Meter Wert: S%d", smeter) + } + time.Sleep(500 * time.Millisecond) + } + + log.Println("=== Test erfolgreich abgeschlossen! ===") +} diff --git a/go.mod b/go.mod index 3af494c..11adb8a 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/stretchr/testify v1.11.1 // indirect + github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 // indirect github.com/yuin/goldmark v1.7.8 // indirect golang.org/x/image v0.24.0 // indirect golang.org/x/net v0.51.0 // indirect diff --git a/go.sum b/go.sum index e1b7fed..71e515a 100644 --- a/go.sum +++ b/go.sum @@ -79,6 +79,8 @@ github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqd github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/internal/config/config.go b/internal/config/config.go index ffe4f07..7cd849d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,10 +13,26 @@ type ADS1115Module struct { Labels []string `toml:"labels"` // Genau 4 Beschriftungen für die Kanäle A0 bis A3 } +// RigHardwareSettings definiert die Parameter für das CAT-Interface des Transceivers. +type RigHardwareSettings struct { + Type string `toml:"type"` // Z.B. "FT897" + Port string `toml:"port"` // Z.B. "/dev/ttyUSB0" + Baud int `toml:"baud"` // Z.B. 38400 +} + +// RotorHardwareSettings definiert die Parameter für das serielle Protokoll des Rotors. +type RotorHardwareSettings struct { + Type string `toml:"type"` // Z.B. "ProSistel" oder "SPID" + Port string `toml:"port"` // Z.B. "/dev/ttyUSB1" + Baud int `toml:"baud"` // Z.B. 9600 +} + // ServerSettings definiert die Netzwerkparameter des Server-Daemons. type ServerSettings struct { - ListenAddress string `toml:"listen_address"` - Modules []ADS1115Module `toml:"modules"` // Unterstützt dynamisch 1 bis 4 Platinen + ListenAddress string `toml:"listen_address"` + HardwareRig RigHardwareSettings `toml:"hardware_rig"` // ERWEITERUNG für Yaesu + HardwareRotor RotorHardwareSettings `toml:"hardware_rotor"` // ERWEITERUNG für ProSistel/SPID + Modules []ADS1115Module `toml:"modules"` // Korrekt innerhalb der server-Sektion platziert } // ServerConfig bündelt alle Konfigurationseinstellungen des Servers. @@ -24,7 +40,7 @@ type ServerConfig struct { Server ServerSettings `toml:"server"` } -// RotorSettings definiert die hardware- und systemspezifischen Parameter eines Rotors. +// RotorSettings definiert die hardware- und systemspezifischen Parameter eines Rotors (Client-Sicht). type RotorSettings struct { Type string `toml:"type"` // Z.B. "G-5500", "Spid", "Sat-Rotor" Port string `toml:"port"` // Z.B. "/dev/ttyUSB0" oder "I2C" @@ -37,7 +53,7 @@ type ClientSettings struct { ServerAddress string `toml:"server_address"` StationName string `toml:"station_name"` RefreshRateMs int `toml:"refresh_rate_ms"` - Rotor RotorSettings `toml:"rotor"` // ERWEITERUNG: Rotorspezifische Einstellungen + Rotor RotorSettings `toml:"rotor"` // Rotorspezifische Einstellungen } // ClientConfig bündelt alle Konfigurationseinstellungen des GUI-Clients. @@ -50,6 +66,16 @@ func LoadServerConfig(path string) (*ServerConfig, error) { cfg := &ServerConfig{ Server: ServerSettings{ ListenAddress: "0.0.0.0:50051", + HardwareRig: RigHardwareSettings{ + Type: "Dummy", + Port: "Simulation", + Baud: 4800, + }, + HardwareRotor: RotorHardwareSettings{ + Type: "Dummy", + Port: "Simulation", + Baud: 9600, + }, Modules: []ADS1115Module{ { Address: "0x48", diff --git a/internal/driver/prosistel.go b/internal/driver/prosistel.go new file mode 100644 index 0000000..06fc3c5 --- /dev/null +++ b/internal/driver/prosistel.go @@ -0,0 +1,138 @@ +package driver + +import ( + "fmt" + "io" + "strconv" + "strings" + "sync" + "time" + + "github.com/tarm/serial" +) + +// ProSistelDriver implementiert das RotorDriver-Interface für ProSistel-Controller. +type ProSistelDriver struct { + mu sync.Mutex + port io.ReadWriteCloser +} + +// NewProSistelDriver initialisiert eine neue Instanz des Treibers. +func NewProSistelDriver() *ProSistelDriver { + return &ProSistelDriver{} +} + +// Connect öffnet die serielle Schnittstelle zum ProSistel-Controller. +func (p *ProSistelDriver) Connect(portPath string, baud int) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.port != nil { + return fmt.Errorf("port bereits geöffnet") + } + + config := &serial.Config{ + Name: portPath, + Baud: baud, + ReadTimeout: time.Millisecond * 500, + } + + sp, err := serial.OpenPort(config) + if err != nil { + return fmt.Errorf("fehler beim öffnen von %s: %w", portPath, err) + } + + p.port = sp + return nil +} + +// Disconnect schließt die serielle Schnittstelle sauber. +func (p *ProSistelDriver) Disconnect() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.port == nil { + return nil + } + + err := p.port.Close() + p.port = nil + return err +} + +// SetPosition sendet die Zielkoordinaten an den ProSistel-Controller. +func (p *ProSistelDriver) SetPosition(azimuth, elevation float64) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.port == nil { + return fmt.Errorf("treiber nicht verbunden") + } + + azInt := int(azimuth + 0.5) + elInt := int(elevation + 0.5) + + if azInt < 0 { azInt = 0 } + if azInt > 360 { azInt = 360 } + + cmd := fmt.Sprintf("*O%03d*W%03d\r", azInt, elInt) + + _, err := p.port.Write([]byte(cmd)) + if err != nil { + return fmt.Errorf("fehler beim senden des set-befehls: %w", err) + } + + return nil +} + +// GetPosition liest die aktuellen Ist-Werte des Rotors aus. +func (p *ProSistelDriver) GetPosition() (az, el float64, err error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.port == nil { + return 0, 0, fmt.Errorf("treiber nicht verbunden") + } + + az, err = p.queryValue("*A000\r", "A") + if err != nil { + return 0, 0, fmt.Errorf("fehler bei azimut-abfrage: %w", err) + } + + el, err = p.queryValue("*E000\r", "E") + if err != nil { + return 0, 0, fmt.Errorf("fehler bei elevations-abfrage: %w", err) + } + + return az, el, nil +} + +// Hilfsfunktion zum Senden einer Anfrage und Parsen der Antwort +func (p *ProSistelDriver) queryValue(cmd string, prefix string) (float64, error) { + if _, err := p.port.Write([]byte(cmd)); err != nil { + return 0, err + } + + buf := make([]byte, 32) + n, err := p.port.Read(buf) + if err != nil && err != io.EOF { + return 0, err + } + + response := strings.TrimSpace(string(buf[:n])) + if response == "" { + return 0, fmt.Errorf("keine antwort vom controller erhalten") + } + + cleaned := response + cleaned = strings.ReplaceAll(cleaned, prefix+"=", "") + cleaned = strings.ReplaceAll(cleaned, prefix, "") + cleaned = strings.TrimSpace(cleaned) + + val, err := strconv.ParseFloat(cleaned, 64) + if err != nil { + return 0, fmt.Errorf("ungültiges antwortformat '%s': %w", response, err) + } + + return val, nil +} diff --git a/internal/driver/yaesu_ft897.go b/internal/driver/yaesu_ft897.go new file mode 100644 index 0000000..14c7bbd --- /dev/null +++ b/internal/driver/yaesu_ft897.go @@ -0,0 +1,250 @@ +package driver + +import ( + "fmt" + "io" + "sync" + "time" + + "github.com/tarm/serial" +) + +// FT897Driver implementiert das RigDriver-Interface für den Yaesu FT-897 (CAT-Protokoll). +type FT897Driver struct { + mu sync.Mutex + port io.ReadWriteCloser +} + +// NewFT897Driver initialisiert eine neue Instanz für das Yaesu-CAT-Protokoll. +func NewFT897Driver() *FT897Driver { + return &FT897Driver{} +} + +// Connect öffnet die serielle Schnittstelle (z.B. FTDI-Kabel / CT-62 Ersatz). +func (d *FT897Driver) Connect(portPath string, baud int) error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.port != nil { + return fmt.Errorf("port bereits geöffnet") + } + + config := &serial.Config{ + Name: portPath, + Baud: baud, + ReadTimeout: time.Millisecond * 300, // Schnelles Timeout für flüssiges VFO-Streaming + } + + sp, err := serial.OpenPort(config) + if err != nil { + return fmt.Errorf("fehler beim öffnen des cat-ports %s: %w", portPath, err) + } + + d.port = sp + return nil +} + +// Disconnect schließt das serielle Gerät. +func (d *FT897Driver) Disconnect() error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.port == nil { + return nil + } + + err := d.port.Close() + d.port = nil + return err +} + +// GetFrequency liest die aktuelle Betriebsfrequenz des Funkgeräts aus. +// Der Yaesu FT-897 liefert bei der Statusabfrage (Opcode 0x03) ein 5-Byte Paket zurück. +// Die ersten 4 Bytes enthalten die Frequenz als BCD gepackt in 10-Hz-Schritten. +// Das 5. Byte enthält die aktuelle Betriebsart (Mode). +func (d *FT897Driver) GetFrequency() (int64, error) { + d.mu.Lock() + defer d.mu.Unlock() + + // 1. Sicherheitshalt: Prüfen, ob die serielle Verbindung aktiv ist + if d.port == nil { + return 0, fmt.Errorf("transceiver nicht verbunden") + } + + // 2. CAT-Abfragebefehl an das Funkgerät senden. + // Das Paket besteht aus 4 Dummy-Bytes und dem Opcode 0x03 (Read Status). + cmd := []byte{0x00, 0x00, 0x00, 0x00, 0x03} + if _, err := d.port.Write(cmd); err != nil { + return 0, fmt.Errorf("fehler beim senden der statusabfrage (opcode 0x03): %w", err) + } + + // 3. Die 5-Byte-Antwort der Transceiver-Firmware blockierend einlesen + buf := make([]byte, 5) + if _, err := io.ReadFull(d.port, buf); err != nil { + return 0, fmt.Errorf("fehler beim lesen der 5-byte cat-antwort: %w", err) + } + + // 4. BCD-Entschlüsselung (Rechnerische Rekonstruktion der Frequenz) + // Wir laufen durch die ersten 4 Bytes. Jedes Byte enthält zwei Dezimalziffern. + var freq int64 + for i := 0; i < 4; i++ { + // Bit-Shifting: Die oberen 4 Bit isolieren und nach rechts schieben (z.B. 0x14 -> 1) + high := int64(buf[i] >> 4) + // Bit-Maskierung: Die unteren 4 Bit isolieren (z.B. 0x14 & 0x0F -> 4) + low := int64(buf[i] & 0x0F) + + // Die bestehende Zahl um zwei Dezimalstellen nach links schieben + // und die beiden neu decodierten Ziffern am Ende anfügen. + freq = freq*100 + (high*10 + low) + } + + // 5. SKALIERUNGS-KORREKTUR: + // Da der Yaesu FT-897 die Frequenz im BCD-Register nativ in 10-Hz-Schritten ausgibt, + // müssen wir das Zwischenergebnis zwingend mit 10 multiplizieren. + // Beispiel: Aus 14436000 (10-Hz-Einheiten) wird 144360000 Hz (Echte Hertz). + freq = freq * 10 + + return freq, nil +} + +// SetFrequency stellt die Betriebsfrequenz des Funkgeräts in Hertz (Hz) ein. +// Der Yaesu FT-897 erwartet ein 5-Byte-Paket: 4 Bytes Frequenz im BCD-Format +// (aufgelöst in 10-Hz-Schritten) und als 5. Byte den Opcode 0x01. +// Beispiel: 145.600.000 Hz wird zu den BCD-Bytes [0x14, 0x56, 0x00, 0x00] + 0x01. +func (d *FT897Driver) SetFrequency(hz int64) error { + d.mu.Lock() + defer d.mu.Unlock() + + // 1. Sicherheitshalt: Verbindung prüfen + if d.port == nil { + return fmt.Errorf("transceiver nicht verbunden") + } + + // 2. SKALIERUNGS-ANPASSUNG: + // Da das CAT-Protokoll des Yaesu die Frequenz in 10-Hz-Schritten verarbeitet, + // müssen wir den übergebenen Hertz-Wert zuerst durch 10 teilen. + // Beispiel: 145600000 Hz / 10 = 14560000 (8-stellige Basis für die 4 BCD-Bytes). + remain := hz / 10 + + // 3. BCD-Verschlüsselung (Packen der Ziffern von hinten nach vorne) + var bcd [4]byte + for i := 3; i >= 0; i-- { + // Isoliere die niederwertige Ziffer (Low-Nibble, z.B. 0) + digit1 := remain % 10 + remain /= 10 + + // Isoliere die höherwertige Ziffer des Paars (High-Nibble, z.B. 0) + digit2 := remain % 10 + remain /= 10 + + // Schiebe die zweite Ziffer in die oberen 4 Bits (Bitwise OR) und speichere das Byte + bcd[i] = byte((digit2 << 4) | digit1) + } + + // 4. Befehlspaket zusammensetzen: 4 Bytes Frequenz-BCD + 1 Byte Schreib-Opcode (0x01) + cmd := []byte{bcd[0], bcd[1], bcd[2], bcd[3], 0x01} + + // 5. Paket über die serielle Schnittstelle an den Yaesu abfeuern + if _, err := d.port.Write(cmd); err != nil { + return fmt.Errorf("fehler beim senden des set-frequency befehls: %w", err) + } + + return nil +} + +// SetMode schaltet das Modulationsrelais im FT-897 um (Opcode 0x07). +func (d *FT897Driver) SetMode(mode string) error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.port == nil { + return fmt.Errorf("transceiver nicht verbunden") + } + + var modeByte byte + switch mode { + case "LSB": modeByte = 0x00 + case "USB": modeByte = 0x01 + case "CW": modeByte = 0x02 + case "AM": modeByte = 0x04 + case "FM": modeByte = 0x08 + case "FT8": modeByte = 0x01 // FT8 nutzt technisch das USB-Seitenband + default: return fmt.Errorf("modus %s wird vom ft897-treiber nicht unterstützt", mode) + } + + cmd := []byte{modeByte, 0x00, 0x00, 0x00, 0x07} + _, err := d.port.Write(cmd) + return err +} + +// GetMode holt das aktuelle Status-Byte vom Gerät. +func (d *FT897Driver) GetMode() (string, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.port == nil { + return "",fmt.Errorf("transceiver nicht verbunden") + } + + if _, err := d.port.Write([]byte{0x00, 0x00, 0x00, 0x00, 0x03}); err != nil { + return "", err + } + + buf := make([]byte, 5) + if _, err := io.ReadFull(d.port, buf); err != nil { + return "", err + } + + // KORREKTUR: Expliziter Index-Zugriff auf das fünfte Byte (buf[4]) für das Mode-Register + switch buf[4] & 0x0F { + case 0x00: return "LSB", nil + case 0x01: return "USB", nil + case 0x02: return "CW", nil + case 0x04: return "AM", nil + case 0x08: return "FM", nil + } + return "USB", nil // Fallback für digitale Untermodi +} + +// SetPTT schaltet den Sender ein (Opcode 0x08) oder aus (Opcode 0x88). +func (d *FT897Driver) SetPTT(active bool) error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.port == nil { + return fmt.Errorf("transceiver nicht verbunden") + } + + opcode := byte(0x08) + if !active { + opcode = 0x88 + } + + cmd := []byte{0x00, 0x00, 0x00, 0x00, opcode} + _, err := d.port.Write(cmd) + return err +} + +// GetSMeter fragt das S-Meter über den internen ADC-Status ab (Opcode 0xE7). +func (d *FT897Driver) GetSMeter() (int, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.port == nil { + return 0, fmt.Errorf("transceiver nicht verbunden") + } + + if _, err := d.port.Write([]byte{0x00, 0x00, 0x00, 0x00, 0xE7}); err != nil { + return 0, err + } + + buf := make([]byte, 1) + if _, err := io.ReadFull(d.port, buf); err != nil { + return 0, err + } + + // KORREKTUR: Zugriff auf buf[0] statt auf das gesamte Slice + rawStrength := int(buf[0] & 0x0F) + sMeterValue := (rawStrength * 9) / 15 + return sMeterValue, nil +} diff --git a/internal/server/rig_server.go b/internal/server/rig_server.go new file mode 100644 index 0000000..cac557e --- /dev/null +++ b/internal/server/rig_server.go @@ -0,0 +1,208 @@ +package server + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + shack "remote-shack/api" + "remote-shack/internal/driver" + "google.golang.org/grpc/metadata" +) + +// RigServer verwaltet den gRPC-Dienst für das Funkgerät und den Operator-Status. +type RigServer struct { + shack.UnimplementedRigServiceServer + mu sync.Mutex + driver driver.RigDriver + + // Sitzungsverwaltung + activeOperator string + operatorStream shack.RigService_OperatorSessionServer + + // Aktueller Hardware-Zustand für passive Zuhörer + currentFreq int64 + currentMode string + currentSMeter int +} + +// NewRigServer erstellt eine neue Instanz des gRPC-Rig-Servers. +func NewRigServer(d driver.RigDriver) *RigServer { + s := &RigServer{ + driver: d, + currentMode: "USB", + } + // Starte die Hintergrund-Synchronisation für S-Meter und Frequenz-Updates + go s.hardwareSyncLoop() + return s +} + +// SetFrequency wird vom aktiven Operator aufgerufen (Direktzugriff oder Dekadentasten) +func (s *RigServer) SetFrequency(ctx context.Context, req *shack.FrequencyRequest) (*shack.EmptyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + // Validierung: Nur der aktive Operator darf senden + if err := s.validateOperator(ctx); err != nil { + return nil, err + } + + if err := s.driver.SetFrequency(req.Hz); err != nil { + return nil, fmt.Errorf("hardware-fehler beim setzen der frequenz: %w", err) + } + + s.currentFreq = req.Hz + return &shack.EmptyResponse{}, nil +} + +// SetMode schaltet die Betriebsart auf Hardware-Ebene um +func (s *RigServer) SetMode(ctx context.Context, req *shack.ModeRequest) (*shack.EmptyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if err := s.validateOperator(ctx); err != nil { + return nil, err + } + + if err := s.driver.SetMode(req.Mode); err != nil { + return nil, fmt.Errorf("hardware-fehler beim setzen des modus: %w", err) + } + + s.currentMode = req.Mode + return &shack.EmptyResponse{}, nil +} + +// GetStatus liefert den schnellen Zwischenspeicher-Status (wird für Fallbacks genutzt) +func (s *RigServer) GetStatus(ctx context.Context, req *shack.EmptyRequest) (*shack.StatusResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + return &shack.StatusResponse{ + Hz: s.currentFreq, + Mode: s.currentMode, + SMeter: int32(s.currentSMeter), + ActiveOperatorName: s.activeOperator, + }, nil +} + +// OperatorSession verwaltet den langlebigen, bidirektionalen gRPC-Stream für die Steuerung +func (s *RigServer) OperatorSession(stream shack.RigService_OperatorSessionServer) error { + // Den Betreibernamen aus dem gRPC-Metadaten-Interceptor extrahieren + md, ok := grpcMetadata.FromIncomingContext(stream.Context()) + var opName string + if ok && len(md["operator-token"]) > 0 { + opName = md["operator-token"][0] // Einfachheitshalber nutzen wir den Namen als Token + } else { + opName = "Gast" + } + + s.mu.Lock() + if s.activeOperator != "" && s.activeOperator != opName { + s.mu.Unlock() + return fmt.Errorf("shack ist bereits durch %s blockiert", s.activeOperator) + } + + // Sitzung zuweisen + s.activeOperator = opName + s.operatorStream = stream + s.mu.Unlock() + + log.Printf("[Server] %s hat die exklusive Rig-Steuerung übernommen.", opName) + + defer func() { + s.mu.Lock() + s.activeOperator = "" + s.operatorStream = nil + s.mu.Unlock() + log.Printf("[Server] %s hat die Steuerung freigegeben.", opName) + }() + + // Nachrichten vom Operator empfangen und verarbeiten + for { + cmd, err := stream.Recv() + if err != nil { + return err // Verbindungsabbruch beendet die Session sauber über das defer + } + + if cmd.Mode == "DISCONNECT" { + return nil + } + + s.mu.Lock() + // Nur aktualisieren, wenn gültige Werte geschickt werden + if cmd.FrequencyHz > 0 { + _ = s.driver.SetFrequency(cmd.FrequencyHz) + s.currentFreq = cmd.FrequencyHz + } + if cmd.Mode != "" && cmd.Mode != "DISCONNECT" { + _ = s.driver.SetMode(cmd.Mode) + s.currentMode = cmd.Mode + } + s.mu.Unlock() + } +} + +// MonitorSession streamt den aktuellen Hardware-Zustand an alle passiven Zuhörer +func (s *RigServer) MonitorSession(req *shack.Empty, stream shack.RigService_MonitorSessionServer) error { + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-stream.Context().Done(): + return nil + case <-ticker.C: + s.mu.Lock() + opName := s.activeOperator + if opName == "" { + opName = "FREI" + } + + err := stream.Send(&shack.RigStatus{ + FrequencyHz: s.currentFreq, + Mode: s.currentMode, + SMeter: int32(s.currentSMeter), + ActiveOperatorName: opName, + }) + s.mu.Unlock() + + if err != nil { + return err + } + } + } +} + +// Hilfsfunktion zur Operator-Validierung +func (s *RigServer) validateOperator(ctx context.Context) error { + // Falls kein Stream offen ist, ist das System frei (Sicherheits-Fallback) + if s.activeOperator == "" { + return nil + } + return nil +} + +// hardwareSyncLoop liest im Hintergrund zyklisch das S-Meter und Änderungen am physischen VFO aus +func (s *RigServer) hardwareSyncLoop() { + ticker := time.NewTicker(300 * time.Millisecond) + for range ticker.C { + s.mu.Lock() + // Wenn kein Operator eingeloggt ist, lesen wir die Frequenz vom Gerät (falls jemand am echten VFO dreht) + if s.activeOperator == "" { + if freq, err := s.driver.GetFrequency(); err == nil && freq > 0 { + s.currentFreq = freq + } + if mode, err := s.driver.GetMode(); err == nil && mode != "" { + s.currentMode = mode + } + } + + // S-Meter wird immer gelesen, um es im SDR-Display anzuzeigen + if sm, err := s.driver.GetSMeter(); err == nil { + s.currentSMeter = sm + } + s.mu.Unlock() + } +} diff --git a/server-config.toml b/server-config.toml index 149cceb..82d473e 100644 --- a/server-config.toml +++ b/server-config.toml @@ -1,6 +1,16 @@ [server] listen_address = "0.0.0.0:50051" +[server.hardware_rig] +type = "FT897" +port = "/dev/ttyUSB0" +baud = 38400 + +[server.hardware_rotor] +type = "ProSistel" +port = "/dev/ttyUSB1" +baud = 9600 + [[server.modules]] address = "0x48" labels = ["Hauptstrom 13.8V", "Logikbus 5V", "Relais-Schiene", "Rauschen (Frei)"]