2026-06-18 17:09 Frequenzanzeige funktioniert jetzt

This commit is contained in:
Dieter Lang 2026-06-18 17:10:05 +02:00
parent c954c2292a
commit f82f9bde86
15 changed files with 854 additions and 18 deletions

View file

@ -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)
==============================================================================

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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,
})
}
}
}

74
cmd/test_rig/main.go Normal file
View file

@ -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! ===")
}

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

View file

@ -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",

View file

@ -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
}

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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)"]