up
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user