This commit is contained in:
2026-06-07 02:51:00 +02:00
parent 16c04fc12b
commit 8040a37315
11 changed files with 1150 additions and 224 deletions
+238 -27
View File
@@ -36,6 +36,7 @@ import (
"hamlog/internal/profile"
"hamlog/internal/qso"
"hamlog/internal/rotator/pst"
"hamlog/internal/ultrabeam"
"hamlog/internal/winkeyer"
"hamlog/internal/settings"
@@ -122,6 +123,11 @@ const (
keyRotatorPort = "rotator.port"
keyRotatorHasElevation = "rotator.has_elevation"
// Ultrabeam antenna (TCP, e.g. via an RS232↔Ethernet adapter) — Hardware → Antenna.
keyUltrabeamEnabled = "ultrabeam.enabled"
keyUltrabeamHost = "ultrabeam.host"
keyUltrabeamPort = "ultrabeam.port"
// WinKeyer CW keyer (serial) — Hardware → CW Keyer.
keyWKEnabled = "winkeyer.enabled"
keyWKPort = "winkeyer.port"
@@ -344,6 +350,7 @@ type App struct {
extsvc *extsvc.Manager
winkeyer *winkeyer.Manager
clublog *clublog.Manager
ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled
audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
@@ -351,6 +358,7 @@ type App struct {
pttMu sync.Mutex
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
startupErr string // captured for surfacing to the frontend
dbPath string // active database file (may be a user-chosen location)
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
@@ -670,10 +678,20 @@ func (a *App) startup(ctx context.Context) {
// Digital Voice Keyer + QSO recorder (WASAPI). Idle until used.
a.audioMgr = audio.NewManager(func() {
st := a.dvkStatus()
// When a voice message finishes (or is stopped), drop CAT PTT.
if !st.Playing && a.dvkPttKeyed {
a.dvkPttKeyed = false
go a.dvkUnkeyPTT()
// When a voice message finishes (or is stopped), drop the PTT we keyed
// for it — but tag the release with the current key generation so it
// can't cut a transmission a newer message already started.
if !st.Playing {
a.pttMu.Lock()
keyed := a.dvkPttKeyed
gen := a.pttGen
if keyed {
a.dvkPttKeyed = false
}
a.pttMu.Unlock()
if keyed {
go a.dvkUnkeyPTT(gen)
}
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "audio:status", st)
@@ -682,6 +700,9 @@ func (a *App) startup(ctx context.Context) {
a.qsoRec = audio.NewRecorder()
a.startQSORecorderIfEnabled()
// Ultrabeam antenna: connect in the background if enabled.
a.startUltrabeam()
fmt.Println("OpsLog: db ready at", a.dbPath)
}
@@ -3890,22 +3911,50 @@ func (a *App) DVKPlay(slot int) error {
applog.Printf("dvk: PTT on failed: %v", err)
// Keep going — the audio still reaches the rig; the user may use VOX.
} else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" {
a.pttMu.Lock()
a.dvkPttKeyed = true
a.pttMu.Unlock()
}
if err := a.audioMgr.Play(cfg.ToRadio, path); err != nil {
if a.dvkPttKeyed {
a.dvkPttKeyed = false
go a.dvkUnkeyPTT()
a.pttMu.Lock()
keyed := a.dvkPttKeyed
gen := a.pttGen
a.dvkPttKeyed = false
a.pttMu.Unlock()
if keyed {
go a.dvkUnkeyPTT(gen)
}
return err
}
return nil
}
// dvkUnkeyPTT releases serial PTT after a short tail so the rig doesn't clip
// the end of the message.
func (a *App) dvkUnkeyPTT() {
// dvkUnkeyPTT releases PTT after a short tail so the rig doesn't clip the end
// of the message — but ONLY if no newer key happened since (gen unchanged). A
// rapid replay (or a Test PTT) starts a fresh transmission whose key must not
// be cut by this stale, delayed release.
func (a *App) dvkUnkeyPTT(gen int64) {
time.Sleep(120 * time.Millisecond)
a.unkeyIfCurrent(gen)
}
// pttGenNow returns the current PTT key generation.
func (a *App) pttGenNow() int64 {
a.pttMu.Lock()
defer a.pttMu.Unlock()
return a.pttGen
}
// unkeyIfCurrent drops PTT only when the key generation hasn't advanced since
// gen was captured — so a delayed release never cuts a transmission the user
// (or a new DVK message) started in the meantime.
func (a *App) unkeyIfCurrent(gen int64) {
a.pttMu.Lock()
stale := a.pttGen != gen
a.pttMu.Unlock()
if stale {
return
}
a.pttUnkey()
}
@@ -3924,6 +3973,7 @@ func (a *App) pttKey(cfg AudioSettings) error {
}
a.pttMu.Lock()
a.pttKeyedMethod = "cat"
a.pttGen++
a.pttMu.Unlock()
applog.Printf("dvk: PTT keyed (CAT/OmniRig)")
return nil
@@ -3954,6 +4004,7 @@ func (a *App) pttKey(cfg AudioSettings) error {
}
a.pttPort = port
a.pttKeyedMethod = cfg.PTTMethod
a.pttGen++
applog.Printf("dvk: PTT keyed (%s on %s)", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort)
return nil
}
@@ -3990,15 +4041,21 @@ func (a *App) pttUnkey() {
}
// TestPTT keys PTT for ~600ms so the user can confirm the rig transmits.
func (a *App) TestPTT() error {
cfg, _ := a.GetAudioSettings()
// TestPTT keys the transmitter for ~600ms using the GIVEN settings (the live
// UI selection), so the user can test a method/port without saving first —
// matching TestRotator / TestUltrabeam.
func (a *App) TestPTT(cfg AudioSettings) error {
if cfg.PTTMethod == "" || cfg.PTTMethod == "none" {
return fmt.Errorf("PTT method is None (VOX) — nothing to test")
return fmt.Errorf("PTT method is None (VOX) — pick CAT, RTS or DTR first")
}
if (cfg.PTTMethod == "rts" || cfg.PTTMethod == "dtr") && strings.TrimSpace(cfg.PTTPort) == "" {
return fmt.Errorf("select a COM port for %s PTT", strings.ToUpper(cfg.PTTMethod))
}
if err := a.pttKey(cfg); err != nil {
return err
}
go func() { time.Sleep(600 * time.Millisecond); a.pttUnkey() }()
gen := a.pttGenNow()
go func() { time.Sleep(600 * time.Millisecond); a.unkeyIfCurrent(gen) }()
return nil
}
@@ -5941,6 +5998,149 @@ func boolStr(b bool) string {
return "0"
}
// ── Ultrabeam antenna (TCP) ────────────────────────────────────────────
// UltrabeamSettings is the JSON shape for the Hardware → Antenna panel.
type UltrabeamSettings struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
}
// GetUltrabeamSettings returns the persisted Ultrabeam config with defaults.
func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
out := UltrabeamSettings{Port: 23}
if a.settings == nil {
return out, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort)
if err != nil {
return out, err
}
out.Enabled = m[keyUltrabeamEnabled] == "1"
out.Host = m[keyUltrabeamHost]
if p, _ := strconv.Atoi(m[keyUltrabeamPort]); p > 0 && p <= 65535 {
out.Port = p
}
return out, nil
}
// SaveUltrabeamSettings persists the config and (re)starts or stops the TCP
// poller so the change takes effect immediately.
func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if s.Port <= 0 || s.Port > 65535 {
s.Port = 23
}
for k, v := range map[string]string{
keyUltrabeamEnabled: boolStr(s.Enabled),
keyUltrabeamHost: strings.TrimSpace(s.Host),
keyUltrabeamPort: strconv.Itoa(s.Port),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
a.startUltrabeam()
return nil
}
// startUltrabeam stops any existing client and starts a fresh one if the
// antenna is enabled and configured. Safe to call repeatedly (on startup and
// after a settings save).
func (a *App) startUltrabeam() {
if a.ultrabeam != nil {
a.ultrabeam.Stop()
a.ultrabeam = nil
}
s, err := a.GetUltrabeamSettings()
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
return
}
a.ultrabeam = ultrabeam.New(s.Host, s.Port)
_ = a.ultrabeam.Start()
}
// UltrabeamStatusInfo is the live antenna status for the UI (status bar +
// direction control). Enabled mirrors the setting; the rest comes from the
// device's most recent status poll.
type UltrabeamStatusInfo struct {
Enabled bool `json:"enabled"`
Connected bool `json:"connected"`
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bidirectional
Frequency int `json:"frequency"` // KHz
Band int `json:"band"`
Moving bool `json:"moving"`
}
// GetUltrabeamStatus returns the antenna's current state for the UI poll.
func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
out := UltrabeamStatusInfo{}
s, _ := a.GetUltrabeamSettings()
out.Enabled = s.Enabled
if a.ultrabeam == nil {
return out
}
st, err := a.ultrabeam.GetStatus()
if err != nil || st == nil {
return out
}
out.Connected = st.Connected
out.Direction = st.Direction
out.Frequency = st.Frequency
out.Band = st.Band
out.Moving = st.MotorsMoving != 0
return out
}
// SetUltrabeamDirection switches the antenna pattern: 0=normal, 1=180°,
// 2=bidirectional (re-issues the current frequency with the new direction).
func (a *App) SetUltrabeamDirection(direction int) error {
if a.ultrabeam == nil {
return fmt.Errorf("Ultrabeam not connected — enable it in Settings → Antenna")
}
if direction < 0 || direction > 2 {
return fmt.Errorf("invalid direction %d", direction)
}
return a.ultrabeam.SetDirection(direction)
}
// UltrabeamRetract retracts all elements (storage / safe position).
func (a *App) UltrabeamRetract() error {
if a.ultrabeam == nil {
return fmt.Errorf("Ultrabeam not connected")
}
return a.ultrabeam.Retract()
}
// TestUltrabeam opens a one-shot TCP connection and reads one status frame to
// verify host/port without disturbing the running poller.
func (a *App) TestUltrabeam(s UltrabeamSettings) error {
if strings.TrimSpace(s.Host) == "" {
return fmt.Errorf("host required")
}
if s.Port <= 0 || s.Port > 65535 {
s.Port = 23
}
c := ultrabeam.New(s.Host, s.Port)
if err := c.Start(); err != nil {
return err
}
defer c.Stop()
// The poller connects + reads status on its 2s tick; give it a couple of
// cycles to come up, then check we got a live status frame.
deadline := time.Now().Add(6 * time.Second)
for time.Now().Before(deadline) {
time.Sleep(500 * time.Millisecond)
if st, err := c.GetStatus(); err == nil && st != nil && st.Connected {
return nil
}
}
return fmt.Errorf("no response from %s:%d", s.Host, s.Port)
}
// --- WinKeyer (CW keyer) bindings ---
// WKMacro is one CW message slot (F1…): a short label + the macro text, which
@@ -6430,20 +6630,28 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if a.qso == nil {
return out
}
// Pass a cty.dat-backed resolver so the past-QSO map uses the SAME
// entity name we'll compare each spot against. Without it QRZ-stored
// "Turkey" wouldn't match cty.dat's "Asiatic Turkey" → false NEW.
resolveEntity := func(callsign string) string {
if a.dxcc == nil {
return ""
// Compare by DXCC entity NUMBER, not name. For each logged QSO the key is
// its stored DXCC if present (the authoritative value set at log time, incl.
// ClubLog date exceptions), else the stored country resolved to a number,
// else the cty.dat prefix lookup. This fixes false NEWs where cty.dat
// re-resolves a logged callsign to a different entity than how it was logged
// (e.g. VK2/SP9FIH logged as Lord Howe Island, but its prefix is Australia —
// so a VJ2L Lord Howe spot must still count as worked).
keyFor := func(call string, storedDXCC int, country string) int {
if storedDXCC > 0 {
return storedDXCC
}
m, ok := a.dxcc.Lookup(callsign)
if !ok || m.Entity == nil {
return ""
if n := dxcc.EntityDXCC(country); n > 0 {
return n
}
return m.Entity.Name
if a.dxcc != nil {
if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
return dxcc.EntityDXCC(m.Entity.Name)
}
}
return 0
}
entities, err := a.qso.EntitySlotMap(a.ctx, resolveEntity)
entities, err := a.qso.EntitySlotMap(a.ctx, keyFor)
if err != nil {
return out
}
@@ -6467,10 +6675,13 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if !ok || m.Entity == nil {
continue
}
country := strings.ToLower(m.Entity.Name)
out[i].Country = m.Entity.Name
out[i].Continent = m.Continent
e, worked := entities[country]
dxccNum := dxcc.EntityDXCC(m.Entity.Name)
if dxccNum == 0 {
continue // can't resolve the spot's entity number → don't guess
}
e, worked := entities[dxccNum]
if !worked {
out[i].Status = "new"
continue