up
This commit is contained in:
@@ -36,6 +36,7 @@ import (
|
|||||||
"hamlog/internal/profile"
|
"hamlog/internal/profile"
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
"hamlog/internal/rotator/pst"
|
"hamlog/internal/rotator/pst"
|
||||||
|
"hamlog/internal/ultrabeam"
|
||||||
"hamlog/internal/winkeyer"
|
"hamlog/internal/winkeyer"
|
||||||
"hamlog/internal/settings"
|
"hamlog/internal/settings"
|
||||||
|
|
||||||
@@ -122,6 +123,11 @@ const (
|
|||||||
keyRotatorPort = "rotator.port"
|
keyRotatorPort = "rotator.port"
|
||||||
keyRotatorHasElevation = "rotator.has_elevation"
|
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.
|
// WinKeyer CW keyer (serial) — Hardware → CW Keyer.
|
||||||
keyWKEnabled = "winkeyer.enabled"
|
keyWKEnabled = "winkeyer.enabled"
|
||||||
keyWKPort = "winkeyer.port"
|
keyWKPort = "winkeyer.port"
|
||||||
@@ -344,6 +350,7 @@ type App struct {
|
|||||||
extsvc *extsvc.Manager
|
extsvc *extsvc.Manager
|
||||||
winkeyer *winkeyer.Manager
|
winkeyer *winkeyer.Manager
|
||||||
clublog *clublog.Manager
|
clublog *clublog.Manager
|
||||||
|
ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled
|
||||||
audioMgr *audio.Manager
|
audioMgr *audio.Manager
|
||||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||||
@@ -351,6 +358,7 @@ type App struct {
|
|||||||
pttMu sync.Mutex
|
pttMu sync.Mutex
|
||||||
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
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
|
startupErr string // captured for surfacing to the frontend
|
||||||
dbPath string // active database file (may be a user-chosen location)
|
dbPath string // active database file (may be a user-chosen location)
|
||||||
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
|
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.
|
// Digital Voice Keyer + QSO recorder (WASAPI). Idle until used.
|
||||||
a.audioMgr = audio.NewManager(func() {
|
a.audioMgr = audio.NewManager(func() {
|
||||||
st := a.dvkStatus()
|
st := a.dvkStatus()
|
||||||
// When a voice message finishes (or is stopped), drop CAT PTT.
|
// When a voice message finishes (or is stopped), drop the PTT we keyed
|
||||||
if !st.Playing && a.dvkPttKeyed {
|
// for it — but tag the release with the current key generation so it
|
||||||
a.dvkPttKeyed = false
|
// can't cut a transmission a newer message already started.
|
||||||
go a.dvkUnkeyPTT()
|
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 {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "audio:status", st)
|
wruntime.EventsEmit(a.ctx, "audio:status", st)
|
||||||
@@ -682,6 +700,9 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.qsoRec = audio.NewRecorder()
|
a.qsoRec = audio.NewRecorder()
|
||||||
a.startQSORecorderIfEnabled()
|
a.startQSORecorderIfEnabled()
|
||||||
|
|
||||||
|
// Ultrabeam antenna: connect in the background if enabled.
|
||||||
|
a.startUltrabeam()
|
||||||
|
|
||||||
fmt.Println("OpsLog: db ready at", a.dbPath)
|
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)
|
applog.Printf("dvk: PTT on failed: %v", err)
|
||||||
// Keep going — the audio still reaches the rig; the user may use VOX.
|
// Keep going — the audio still reaches the rig; the user may use VOX.
|
||||||
} else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" {
|
} else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" {
|
||||||
|
a.pttMu.Lock()
|
||||||
a.dvkPttKeyed = true
|
a.dvkPttKeyed = true
|
||||||
|
a.pttMu.Unlock()
|
||||||
}
|
}
|
||||||
if err := a.audioMgr.Play(cfg.ToRadio, path); err != nil {
|
if err := a.audioMgr.Play(cfg.ToRadio, path); err != nil {
|
||||||
if a.dvkPttKeyed {
|
a.pttMu.Lock()
|
||||||
a.dvkPttKeyed = false
|
keyed := a.dvkPttKeyed
|
||||||
go a.dvkUnkeyPTT()
|
gen := a.pttGen
|
||||||
|
a.dvkPttKeyed = false
|
||||||
|
a.pttMu.Unlock()
|
||||||
|
if keyed {
|
||||||
|
go a.dvkUnkeyPTT(gen)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// dvkUnkeyPTT releases serial PTT after a short tail so the rig doesn't clip
|
// dvkUnkeyPTT releases PTT after a short tail so the rig doesn't clip the end
|
||||||
// the end of the message.
|
// of the message — but ONLY if no newer key happened since (gen unchanged). A
|
||||||
func (a *App) dvkUnkeyPTT() {
|
// 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)
|
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()
|
a.pttUnkey()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3924,6 +3973,7 @@ func (a *App) pttKey(cfg AudioSettings) error {
|
|||||||
}
|
}
|
||||||
a.pttMu.Lock()
|
a.pttMu.Lock()
|
||||||
a.pttKeyedMethod = "cat"
|
a.pttKeyedMethod = "cat"
|
||||||
|
a.pttGen++
|
||||||
a.pttMu.Unlock()
|
a.pttMu.Unlock()
|
||||||
applog.Printf("dvk: PTT keyed (CAT/OmniRig)")
|
applog.Printf("dvk: PTT keyed (CAT/OmniRig)")
|
||||||
return nil
|
return nil
|
||||||
@@ -3954,6 +4004,7 @@ func (a *App) pttKey(cfg AudioSettings) error {
|
|||||||
}
|
}
|
||||||
a.pttPort = port
|
a.pttPort = port
|
||||||
a.pttKeyedMethod = cfg.PTTMethod
|
a.pttKeyedMethod = cfg.PTTMethod
|
||||||
|
a.pttGen++
|
||||||
applog.Printf("dvk: PTT keyed (%s on %s)", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort)
|
applog.Printf("dvk: PTT keyed (%s on %s)", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -3990,15 +4041,21 @@ func (a *App) pttUnkey() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestPTT keys PTT for ~600ms so the user can confirm the rig transmits.
|
// TestPTT keys PTT for ~600ms so the user can confirm the rig transmits.
|
||||||
func (a *App) TestPTT() error {
|
// TestPTT keys the transmitter for ~600ms using the GIVEN settings (the live
|
||||||
cfg, _ := a.GetAudioSettings()
|
// 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" {
|
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 {
|
if err := a.pttKey(cfg); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5941,6 +5998,149 @@ func boolStr(b bool) string {
|
|||||||
return "0"
|
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 ---
|
// --- WinKeyer (CW keyer) bindings ---
|
||||||
|
|
||||||
// WKMacro is one CW message slot (F1…): a short label + the macro text, which
|
// 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 {
|
if a.qso == nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
// Pass a cty.dat-backed resolver so the past-QSO map uses the SAME
|
// Compare by DXCC entity NUMBER, not name. For each logged QSO the key is
|
||||||
// entity name we'll compare each spot against. Without it QRZ-stored
|
// its stored DXCC if present (the authoritative value set at log time, incl.
|
||||||
// "Turkey" wouldn't match cty.dat's "Asiatic Turkey" → false NEW.
|
// ClubLog date exceptions), else the stored country resolved to a number,
|
||||||
resolveEntity := func(callsign string) string {
|
// else the cty.dat prefix lookup. This fixes false NEWs where cty.dat
|
||||||
if a.dxcc == nil {
|
// re-resolves a logged callsign to a different entity than how it was logged
|
||||||
return ""
|
// (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 n := dxcc.EntityDXCC(country); n > 0 {
|
||||||
if !ok || m.Entity == nil {
|
return n
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
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 {
|
if err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -6467,10 +6675,13 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
if !ok || m.Entity == nil {
|
if !ok || m.Entity == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
country := strings.ToLower(m.Entity.Name)
|
|
||||||
out[i].Country = m.Entity.Name
|
out[i].Country = m.Entity.Name
|
||||||
out[i].Continent = m.Continent
|
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 {
|
if !worked {
|
||||||
out[i].Status = "new"
|
out[i].Status = "new"
|
||||||
continue
|
continue
|
||||||
|
|||||||
+193
-138
@@ -16,6 +16,7 @@ import {
|
|||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
||||||
RefreshCtyDat,
|
RefreshCtyDat,
|
||||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||||
|
GetUltrabeamStatus, SetUltrabeamDirection,
|
||||||
OpenExternalURL,
|
OpenExternalURL,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||||
@@ -306,6 +307,7 @@ export default function App() {
|
|||||||
// CAT — receives live rig state via Wails events.
|
// CAT — receives live rig state via Wails events.
|
||||||
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
||||||
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
|
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
|
||||||
|
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
|
||||||
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
||||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||||
// in Preferences > Hardware > CAT interface.
|
// in Preferences > Hardware > CAT interface.
|
||||||
@@ -579,6 +581,8 @@ export default function App() {
|
|||||||
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
||||||
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(new Set());
|
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(new Set());
|
||||||
const [clusterSearch, setClusterSearch] = useState('');
|
const [clusterSearch, setClusterSearch] = useState('');
|
||||||
|
// Hide spots already worked (exact call worked, or this band+mode slot done).
|
||||||
|
const [clusterHideWorked, setClusterHideWorked] = useState(false);
|
||||||
const [showBandMap, setShowBandMap] = useState(false);
|
const [showBandMap, setShowBandMap] = useState(false);
|
||||||
// Which side the band map docks to (persisted). Toggled from its header.
|
// Which side the band map docks to (persisted). Toggled from its header.
|
||||||
const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
|
const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
|
||||||
@@ -747,6 +751,17 @@ export default function App() {
|
|||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Poll the Ultrabeam antenna for its connection + pattern direction.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try { const s: any = await GetUltrabeamStatus(); if (alive) setUbStatus(s); } catch {}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 3000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// RX band auto-follows the TX band (only differs for cross-band work).
|
// RX band auto-follows the TX band (only differs for cross-band work).
|
||||||
useEffect(() => { setBandRx(band); }, [band]);
|
useEffect(() => { setBandRx(band); }, [band]);
|
||||||
|
|
||||||
@@ -2438,142 +2453,7 @@ export default function App() {
|
|||||||
<Badge variant="secondary" className="text-[10px]">{spots.length} live</Badge>
|
<Badge variant="secondary" className="text-[10px]">{spots.length} live</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: filters */}
|
{/* Filters moved to the right-side panel (see below). */}
|
||||||
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-border/60 flex-wrap text-xs">
|
|
||||||
<Input
|
|
||||||
className="w-32 h-7 text-xs font-mono uppercase"
|
|
||||||
placeholder="Search call…"
|
|
||||||
value={clusterSearch}
|
|
||||||
onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground">Bands:</span>
|
|
||||||
{bands.map((b) => {
|
|
||||||
const on = clusterBands.has(b);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={b}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setClusterBands((s) => {
|
|
||||||
const n = new Set(s);
|
|
||||||
if (n.has(b)) n.delete(b); else n.add(b);
|
|
||||||
return n;
|
|
||||||
})}
|
|
||||||
className={cn(
|
|
||||||
'px-1.5 py-0.5 rounded border text-[10px] font-mono transition-colors',
|
|
||||||
on
|
|
||||||
? 'bg-primary text-primary-foreground border-primary'
|
|
||||||
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{b}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{clusterBands.size > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setClusterBands(new Set())}
|
|
||||||
className="text-[10px] text-muted-foreground hover:text-foreground underline"
|
|
||||||
title="Clear band filter"
|
|
||||||
>
|
|
||||||
clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="w-px h-4 bg-border mx-1" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setClusterLockBand((v) => !v)}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] transition-colors',
|
|
||||||
clusterLockBand
|
|
||||||
? 'bg-amber-100 text-amber-800 border-amber-300'
|
|
||||||
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
|
|
||||||
)}
|
|
||||||
title="Only show spots on the entry strip's current band"
|
|
||||||
>
|
|
||||||
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />}
|
|
||||||
Lock band ({band})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setClusterLockMode((v) => !v)}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] transition-colors',
|
|
||||||
clusterLockMode
|
|
||||||
? 'bg-amber-100 text-amber-800 border-amber-300'
|
|
||||||
: 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
|
|
||||||
)}
|
|
||||||
title="Only show spots whose mode matches the entry strip"
|
|
||||||
>
|
|
||||||
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />}
|
|
||||||
Lock mode ({mode})
|
|
||||||
</button>
|
|
||||||
<div className="w-px h-4 bg-border mx-1" />
|
|
||||||
<span className="text-muted-foreground">Status:</span>
|
|
||||||
{([
|
|
||||||
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
|
|
||||||
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
|
|
||||||
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
|
||||||
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
|
|
||||||
]).map((s) => {
|
|
||||||
const on = clusterStatusFilter.has(s.k);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={s.k}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setClusterStatusFilter((cur) => {
|
|
||||||
const n = new Set(cur);
|
|
||||||
if (n.has(s.k)) n.delete(s.k); else n.add(s.k);
|
|
||||||
return n;
|
|
||||||
})}
|
|
||||||
className={cn(
|
|
||||||
'px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity',
|
|
||||||
on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="w-px h-4 bg-border mx-1" />
|
|
||||||
<span className="text-muted-foreground">Mode:</span>
|
|
||||||
{([
|
|
||||||
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' },
|
|
||||||
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
|
|
||||||
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
|
|
||||||
]).map((s) => {
|
|
||||||
const on = clusterModeFilter.has(s.k);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={s.k}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setClusterModeFilter((cur) => {
|
|
||||||
const n = new Set(cur);
|
|
||||||
if (n.has(s.k)) n.delete(s.k); else n.add(s.k);
|
|
||||||
return n;
|
|
||||||
})}
|
|
||||||
className={cn(
|
|
||||||
'px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity',
|
|
||||||
on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="flex-1" />
|
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
|
|
||||||
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
|
|
||||||
Group
|
|
||||||
</label>
|
|
||||||
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
|
|
||||||
<SelectTrigger className="w-32 h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="_">All sources</SelectItem>
|
|
||||||
{clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
// Apply every filter. `bandsActive` is the band set the
|
// Apply every filter. `bandsActive` is the band set the
|
||||||
@@ -2601,6 +2481,18 @@ export default function App() {
|
|||||||
const st = spotStatus[k]?.status || '';
|
const st = spotStatus[k]?.status || '';
|
||||||
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false;
|
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false;
|
||||||
}
|
}
|
||||||
|
// Hide worked: drop spots whose exact call is already worked,
|
||||||
|
// or whose entity+band+mode slot is already in the log. The
|
||||||
|
// status is resolved asynchronously, so we also hide spots
|
||||||
|
// whose status isn't known yet — otherwise a worked spot would
|
||||||
|
// flash in (no status) then vanish once it resolves. A new
|
||||||
|
// spot waits for its status, then appears only if not worked.
|
||||||
|
if (clusterHideWorked) {
|
||||||
|
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||||
|
const e = spotStatus[k];
|
||||||
|
if (!e) return false;
|
||||||
|
if (e.worked_call || e.status === 'worked') return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
let rendered = list as (ClusterSpot & { repeats?: number })[];
|
let rendered = list as (ClusterSpot & { repeats?: number })[];
|
||||||
@@ -2678,8 +2570,135 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>{/* /left column */}
|
</div>{/* /left column */}
|
||||||
{/* BandMap moved to a global side panel below — toggle is
|
|
||||||
now in the topbar, visible on every tab. */}
|
{/* Right-side filter panel (Log4OM style) */}
|
||||||
|
<div className="w-56 shrink-0 border-l border-border/60 flex flex-col min-h-0 bg-muted/10">
|
||||||
|
<div className="px-2.5 py-2 border-b border-border/60 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Filters</div>
|
||||||
|
<div className="flex-1 overflow-auto p-2.5 space-y-3 text-xs">
|
||||||
|
{/* Callsign search */}
|
||||||
|
<Input
|
||||||
|
className="h-7 text-xs font-mono uppercase"
|
||||||
|
placeholder="Search call…"
|
||||||
|
value={clusterSearch}
|
||||||
|
onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Toggles */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<Checkbox checked={clusterHideWorked} onCheckedChange={(c) => setClusterHideWorked(!!c)} />
|
||||||
|
Hide worked
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
|
||||||
|
Group duplicates
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Band filter — multi-select listbox */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">Bands</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setClusterLockBand((v) => !v)}
|
||||||
|
className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
|
||||||
|
clusterLockBand ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
|
||||||
|
title="Lock to the entry strip's current band"
|
||||||
|
>
|
||||||
|
{clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
|
||||||
|
</button>
|
||||||
|
{clusterBands.size > 0 && (
|
||||||
|
<button type="button" onClick={() => setClusterBands(new Set())} className="text-[10px] text-muted-foreground hover:text-foreground underline">clear</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cn('rounded border border-border max-h-36 overflow-auto bg-background', clusterLockBand && 'opacity-40 pointer-events-none')}>
|
||||||
|
{bands.map((b) => {
|
||||||
|
const on = clusterBands.has(b);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={b}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setClusterBands((s) => { const n = new Set(s); if (n.has(b)) n.delete(b); else n.add(b); return n; })}
|
||||||
|
className={cn('block w-full text-left px-2 py-0.5 font-mono text-[11px] border-b border-border/30 last:border-0',
|
||||||
|
on ? 'bg-primary text-primary-foreground' : 'hover:bg-accent/50')}
|
||||||
|
>
|
||||||
|
{b}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode lock */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setClusterLockMode((v) => !v)}
|
||||||
|
className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px]',
|
||||||
|
clusterLockMode ? 'bg-amber-100 text-amber-800 border-amber-300' : 'text-muted-foreground border-border hover:bg-muted')}
|
||||||
|
title="Only show spots whose mode matches the entry strip"
|
||||||
|
>
|
||||||
|
{clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} Lock mode ({mode})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{([
|
||||||
|
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
|
||||||
|
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
|
||||||
|
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
||||||
|
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
|
||||||
|
]).map((s) => {
|
||||||
|
const on = clusterStatusFilter.has(s.k);
|
||||||
|
return (
|
||||||
|
<button key={s.k} type="button"
|
||||||
|
onClick={() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
|
||||||
|
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Mode</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{([
|
||||||
|
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' },
|
||||||
|
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
|
||||||
|
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
|
||||||
|
]).map((s) => {
|
||||||
|
const on = clusterModeFilter.has(s.k);
|
||||||
|
return (
|
||||||
|
<button key={s.k} type="button"
|
||||||
|
onClick={() => setClusterModeFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
|
||||||
|
className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Source</div>
|
||||||
|
<Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
|
||||||
|
<SelectTrigger className="w-full h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_">All sources</SelectItem>
|
||||||
|
{clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
@@ -2779,6 +2798,42 @@ export default function App() {
|
|||||||
disabled={!rotatorHeading.enabled}
|
disabled={!rotatorHeading.enabled}
|
||||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||||
/>
|
/>
|
||||||
|
{ubStatus.enabled && (
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-1 px-2 h-5 rounded border border-border text-[11px]"
|
||||||
|
title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam connected') : 'Ultrabeam: connecting…'}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 cursor-pointer hover:text-foreground text-muted-foreground"
|
||||||
|
onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }}
|
||||||
|
title="Antenna settings"
|
||||||
|
>
|
||||||
|
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-amber-500' : 'bg-emerald-500') : 'bg-muted-foreground/40')} />
|
||||||
|
Ant
|
||||||
|
</button>
|
||||||
|
{([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: '180°' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.d}
|
||||||
|
type="button"
|
||||||
|
disabled={!ubStatus.connected}
|
||||||
|
title={o.t}
|
||||||
|
onClick={() => {
|
||||||
|
SetUltrabeamDirection(o.d)
|
||||||
|
.then(() => setUbStatus((s) => ({ ...s, direction: o.d })))
|
||||||
|
.catch((e: any) => setError(String(e?.message ?? e)));
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'px-1 rounded text-[10px] font-medium transition-colors',
|
||||||
|
ubStatus.direction === o.d ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted',
|
||||||
|
!ubStatus.connected && 'opacity-40 cursor-default',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{o.l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -125,17 +125,18 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
const isNew = status?.status === 'new';
|
const isNew = status?.status === 'new';
|
||||||
const workedCall = !!status?.worked_call;
|
const workedCall = !!status?.worked_call;
|
||||||
const style: any = {
|
const style: any = {
|
||||||
display: 'inline-block', padding: '1px 6px', borderRadius: 4,
|
|
||||||
fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 12,
|
fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 12,
|
||||||
};
|
};
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
|
// New DXCC entity — soft rose pill, no clashing border.
|
||||||
style.backgroundColor = '#ffe4e6';
|
style.backgroundColor = '#ffe4e6';
|
||||||
style.color = '#9f1239';
|
style.color = '#be123c';
|
||||||
style.border = '1px solid #fda4af';
|
style.padding = '1px 7px';
|
||||||
|
style.borderRadius = 4;
|
||||||
} else if (workedCall) {
|
} else if (workedCall) {
|
||||||
style.color = '#0369a1';
|
style.color = '#0369a1'; // already worked this exact call
|
||||||
} else {
|
} else {
|
||||||
style.color = '#b8410c';
|
style.color = '#b8410c'; // new call in a worked entity
|
||||||
}
|
}
|
||||||
return <span style={style} title={isNew ? `NEW DXCC: ${status?.country ?? ''}` : workedCall ? 'Already worked this call' : undefined}>{p.value}</span>;
|
return <span style={style} title={isNew ? `NEW DXCC: ${status?.country ?? ''}` : workedCall ? 'Already worked this call' : undefined}>{p.value}</span>;
|
||||||
},
|
},
|
||||||
@@ -164,15 +165,12 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
||||||
];
|
];
|
||||||
const newBand = status?.status === 'new-band';
|
const newBand = status?.status === 'new-band';
|
||||||
const bg = newBand ? '#fde68a' : '#f0d9a8';
|
|
||||||
const fg = newBand ? '#92400e' : '#7a4a14';
|
|
||||||
return p.value
|
return p.value
|
||||||
? <span
|
? <span
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
fontFamily: 'ui-monospace, monospace', fontSize: 12,
|
||||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
fontWeight: newBand ? 700 : 400,
|
||||||
backgroundColor: bg, color: fg, lineHeight: '16px',
|
...(newBand ? { backgroundColor: '#fde68a', color: '#92400e', padding: '1px 7px', borderRadius: 4 } : {}),
|
||||||
border: newBand ? '1px solid #f59e0b' : undefined,
|
|
||||||
}}
|
}}
|
||||||
title={newBand ? 'NEW BAND for this entity' : undefined}
|
title={newBand ? 'NEW BAND for this entity' : undefined}
|
||||||
>{p.value}</span>
|
>{p.value}</span>
|
||||||
@@ -190,15 +188,12 @@ const COL_CATALOG: ColEntry[] = [
|
|||||||
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
spotStatusKey(p.data.dx_call, p.data.band ?? '', p.data.comment ?? '', p.data.freq_hz)
|
||||||
];
|
];
|
||||||
const newSlot = status?.status === 'new-slot';
|
const newSlot = status?.status === 'new-slot';
|
||||||
const bg = newSlot ? '#fef08a' : '#d1fae5';
|
|
||||||
const fg = newSlot ? '#854d0e' : '#047857';
|
|
||||||
return p.value
|
return p.value
|
||||||
? <span
|
? <span
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
|
fontFamily: 'ui-monospace, monospace', fontSize: 12,
|
||||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
|
fontWeight: newSlot ? 700 : 400,
|
||||||
backgroundColor: bg, color: fg, lineHeight: '16px',
|
...(newSlot ? { backgroundColor: '#fef08a', color: '#854d0e', padding: '1px 7px', borderRadius: 4 } : {}),
|
||||||
border: newSlot ? '1px solid #eab308' : undefined,
|
|
||||||
}}
|
}}
|
||||||
title={newSlot ? 'NEW SLOT (mode not yet worked on this band)' : undefined}
|
title={newSlot ? 'NEW SLOT (mode not yet worked on this band)' : undefined}
|
||||||
>{p.value}</span>
|
>{p.value}</span>
|
||||||
|
|||||||
@@ -67,7 +67,39 @@ interface Props {
|
|||||||
|
|
||||||
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
|
||||||
|
|
||||||
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
// ADIF PROP_MODE: stored value is the code, shown with the full name (Log4OM-style).
|
||||||
|
const PROP_MODES: { value: string; label: string }[] = [
|
||||||
|
{ value: 'NONE', label: '—' },
|
||||||
|
{ value: 'AS', label: 'Aircraft Scatter' },
|
||||||
|
{ value: 'AUR', label: 'Aurora' },
|
||||||
|
{ value: 'AUE', label: 'Aurora-E' },
|
||||||
|
{ value: 'BS', label: 'Back Scatter' },
|
||||||
|
{ value: 'ECH', label: 'EchoLink' },
|
||||||
|
{ value: 'EME', label: 'Earth-Moon-Earth' },
|
||||||
|
{ value: 'ES', label: 'Sporadic E' },
|
||||||
|
{ value: 'FAI', label: 'Field Aligned Irregularities' },
|
||||||
|
{ value: 'F2', label: 'F2 Reflection' },
|
||||||
|
{ value: 'GWAVE', label: 'Ground Wave' },
|
||||||
|
{ value: 'INTERNET', label: 'Internet-assisted' },
|
||||||
|
{ value: 'ION', label: 'Ionoscatter' },
|
||||||
|
{ value: 'IRL', label: 'IRLP' },
|
||||||
|
{ value: 'LOS', label: 'Line of Sight' },
|
||||||
|
{ value: 'MS', label: 'Meteor Scatter' },
|
||||||
|
{ value: 'RPT', label: 'Terrestrial / atmospheric repeater' },
|
||||||
|
{ value: 'RS', label: 'Rain Scatter' },
|
||||||
|
{ value: 'SAT', label: 'Satellite' },
|
||||||
|
{ value: 'TEP', label: 'Trans-Equatorial' },
|
||||||
|
{ value: 'TR', label: 'Tropospheric Ducting' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ADIF ANT_PATH enum (Grayline, Other, Short Path, Long Path).
|
||||||
|
const ANT_PATHS: { value: string; label: string }[] = [
|
||||||
|
{ value: 'NONE', label: '—' },
|
||||||
|
{ value: 'S', label: 'Short Path' },
|
||||||
|
{ value: 'L', label: 'Long Path' },
|
||||||
|
{ value: 'G', label: 'Grayline' },
|
||||||
|
{ value: 'O', label: 'Other' },
|
||||||
|
];
|
||||||
|
|
||||||
function numOrUndef(v: string): number | undefined {
|
function numOrUndef(v: string): number | undefined {
|
||||||
if (v === '') return undefined;
|
if (v === '') return undefined;
|
||||||
@@ -76,9 +108,9 @@ function numOrUndef(v: string): number | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compact field helper to keep the JSX dense.
|
// Compact field helper to keep the JSX dense.
|
||||||
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6; children: React.ReactNode }) {
|
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 4 | 6; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 6 && 'col-span-6')}>
|
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 4 && 'col-span-4', span === 6 && 'col-span-6')}>
|
||||||
<Label className="mb-1">{label}</Label>
|
<Label className="mb-1">{label}</Label>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -217,26 +249,31 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
|
|||||||
<Field label="Elevation (°)">
|
<Field label="Elevation (°)">
|
||||||
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Ant. path">
|
|
||||||
<Input value={details.ant_path} placeholder="S / L / G" onChange={(e) => onChange({ ant_path: e.target.value })} />
|
|
||||||
</Field>
|
|
||||||
<Field label="Propagation">
|
|
||||||
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
|
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === 'NONE' ? '—' : p}</SelectItem>)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field label="TX power (W)">
|
<Field label="TX power (W)">
|
||||||
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
||||||
</Field>
|
</Field>
|
||||||
<div className="flex items-end pb-1.5">
|
<div className="col-span-3 flex items-end pb-1.5">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
||||||
Satellite mode
|
Satellite mode
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<Field label="Ant. path" span={2}>
|
||||||
|
<Select value={details.ant_path || 'NONE'} onValueChange={(v) => onChange({ ant_path: v === 'NONE' ? '' : v })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ANT_PATHS.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Propagation" span={4}>
|
||||||
|
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROP_MODES.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
<Field label="Rig" span={3}>
|
<Field label="Rig" span={3}>
|
||||||
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
GetCATSettings, SaveCATSettings,
|
GetCATSettings, SaveCATSettings,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||||
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||||
@@ -193,7 +194,7 @@ const TREE: TreeNode[] = [
|
|||||||
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
|
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
|
||||||
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
|
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
|
||||||
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
|
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
|
||||||
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
|
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna' },
|
||||||
{ kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' },
|
{ kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -368,6 +369,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
|
// Ultrabeam antenna (TCP) settings.
|
||||||
|
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; host: string; port: number }>({
|
||||||
|
enabled: false, host: '', port: 23,
|
||||||
|
});
|
||||||
|
const [ubTesting, setUbTesting] = useState(false);
|
||||||
|
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
// WinKeyer CW keyer settings + macro editor.
|
// WinKeyer CW keyer settings + macro editor.
|
||||||
type WKMac = { label: string; text: string };
|
type WKMac = { label: string; text: string };
|
||||||
type WKSettings = {
|
type WKSettings = {
|
||||||
@@ -583,6 +591,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
await reloadClusterServers();
|
await reloadClusterServers();
|
||||||
setCatCfg(c);
|
setCatCfg(c);
|
||||||
setRotator(r);
|
setRotator(r);
|
||||||
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
setBackupCfg(b as any);
|
setBackupCfg(b as any);
|
||||||
setQslDefaults(qd as any);
|
setQslDefaults(qd as any);
|
||||||
setExtSvc(es as any);
|
setExtSvc(es as any);
|
||||||
@@ -751,6 +760,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
await SaveLookupSettings(lookup as any);
|
await SaveLookupSettings(lookup as any);
|
||||||
await SaveCATSettings(catCfg as any);
|
await SaveCATSettings(catCfg as any);
|
||||||
await SaveRotatorSettings(rotator as any);
|
await SaveRotatorSettings(rotator as any);
|
||||||
|
await SaveUltrabeamSettings(ultrabeam as any);
|
||||||
await SaveWinkeyerSettings(wk as any);
|
await SaveWinkeyerSettings(wk as any);
|
||||||
await SaveAudioSettings(audioCfg as any);
|
await SaveAudioSettings(audioCfg as any);
|
||||||
await SaveEmailSettings(emailCfg as any);
|
await SaveEmailSettings(emailCfg as any);
|
||||||
@@ -1499,6 +1509,74 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testUltrabeam() {
|
||||||
|
setUbTesting(true);
|
||||||
|
setUbTest(null);
|
||||||
|
try {
|
||||||
|
await TestUltrabeam(ultrabeam as any);
|
||||||
|
setUbTest({ ok: true, msg: 'Connected — the Ultrabeam responded with a status frame.' });
|
||||||
|
} catch (e: any) {
|
||||||
|
setUbTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
|
} finally {
|
||||||
|
setUbTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function UltrabeamPanel() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="Antenna (Ultrabeam)"
|
||||||
|
hint="OpsLog talks to the Ultrabeam controller over TCP — typically via an RS232↔Ethernet adapter. Enter its IP address and port; the pattern direction (Normal / 180° / Bidirectional) is then controlled from the status bar."
|
||||||
|
/>
|
||||||
|
<div className="space-y-4 max-w-xl">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
|
||||||
|
Enable Ultrabeam control
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Host / IP</Label>
|
||||||
|
<Input
|
||||||
|
value={ultrabeam.host ?? ''}
|
||||||
|
onChange={(e) => setUltrabeam((s) => ({ ...s, host: e.target.value }))}
|
||||||
|
placeholder="192.168.1.50"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>TCP port</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={1} max={65535}
|
||||||
|
value={ultrabeam.port}
|
||||||
|
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || !ultrabeam.host.trim()}>
|
||||||
|
{ubTesting ? 'Connecting…' : 'Test connection'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{ubTest && (
|
||||||
|
<div className={cn(
|
||||||
|
'text-xs rounded-md p-2.5 border',
|
||||||
|
ubTest.ok
|
||||||
|
? 'bg-emerald-50 text-emerald-800 border-emerald-200'
|
||||||
|
: 'bg-destructive/10 text-destructive border-destructive/30',
|
||||||
|
)}>
|
||||||
|
{ubTest.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Once enabled, the antenna's direction control (Normal / 180° / Bidirectional) appears in the bottom status bar, next to CAT and Rotator. Changing the direction re-tunes the elements at the current frequency.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RotatorPanel() {
|
function RotatorPanel() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -2715,7 +2793,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{audioCfg.ptt_method !== 'none' && (
|
{audioCfg.ptt_method !== 'none' && (
|
||||||
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT().catch((e: any) => setDvkErr('PTT test: ' + String(e?.message ?? e))); }}>
|
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT(audioCfg as any).catch((e: any) => setDvkErr('PTT test: ' + String(e?.message ?? e))); }}>
|
||||||
Test PTT
|
Test PTT
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -2926,7 +3004,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
cat: CATPanel,
|
cat: CATPanel,
|
||||||
rotator: RotatorPanel,
|
rotator: RotatorPanel,
|
||||||
winkeyer: WinkeyerPanel,
|
winkeyer: WinkeyerPanel,
|
||||||
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
|
antenna: UltrabeamPanel,
|
||||||
audio: AudioPanel,
|
audio: AudioPanel,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Vendored
+13
-1
@@ -169,6 +169,10 @@ export function GetStationSettings():Promise<main.StationSettings>;
|
|||||||
|
|
||||||
export function GetUIPref(arg1:string):Promise<string>;
|
export function GetUIPref(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
||||||
|
|
||||||
|
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
||||||
|
|
||||||
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||||
|
|
||||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||||
@@ -293,6 +297,8 @@ export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||||
|
|
||||||
|
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
||||||
|
|
||||||
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
||||||
@@ -317,6 +323,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||||
@@ -329,12 +337,16 @@ export function TestLoTWUpload():Promise<string>;
|
|||||||
|
|
||||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
export function TestPTT():Promise<void>;
|
export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
||||||
|
|
||||||
export function TestQRZUpload():Promise<string>;
|
export function TestQRZUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function UltrabeamRetract():Promise<void>;
|
||||||
|
|
||||||
export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>;
|
export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>;
|
||||||
|
|
||||||
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||||
|
|||||||
@@ -310,6 +310,14 @@ export function GetUIPref(arg1) {
|
|||||||
return window['go']['main']['App']['GetUIPref'](arg1);
|
return window['go']['main']['App']['GetUIPref'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetUltrabeamSettings() {
|
||||||
|
return window['go']['main']['App']['GetUltrabeamSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetUltrabeamStatus() {
|
||||||
|
return window['go']['main']['App']['GetUltrabeamStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetWinkeyerSettings() {
|
export function GetWinkeyerSettings() {
|
||||||
return window['go']['main']['App']['GetWinkeyerSettings']();
|
return window['go']['main']['App']['GetWinkeyerSettings']();
|
||||||
}
|
}
|
||||||
@@ -558,6 +566,10 @@ export function SaveUDPIntegration(arg1) {
|
|||||||
return window['go']['main']['App']['SaveUDPIntegration'](arg1);
|
return window['go']['main']['App']['SaveUDPIntegration'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveUltrabeamSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveUltrabeamSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveWinkeyerSettings(arg1) {
|
export function SaveWinkeyerSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
|
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -606,6 +618,10 @@ export function SetUIPref(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SetUIPref'](arg1, arg2);
|
return window['go']['main']['App']['SetUIPref'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetUltrabeamDirection(arg1) {
|
||||||
|
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SwitchCATRig(arg1) {
|
export function SwitchCATRig(arg1) {
|
||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
@@ -630,8 +646,8 @@ export function TestLookupProvider(arg1, arg2, arg3, arg4) {
|
|||||||
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestPTT() {
|
export function TestPTT(arg1) {
|
||||||
return window['go']['main']['App']['TestPTT']();
|
return window['go']['main']['App']['TestPTT'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestQRZUpload() {
|
export function TestQRZUpload() {
|
||||||
@@ -642,6 +658,14 @@ export function TestRotator(arg1) {
|
|||||||
return window['go']['main']['App']['TestRotator'](arg1);
|
return window['go']['main']['App']['TestRotator'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestUltrabeam(arg1) {
|
||||||
|
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UltrabeamRetract() {
|
||||||
|
return window['go']['main']['App']['UltrabeamRetract']();
|
||||||
|
}
|
||||||
|
|
||||||
export function UpdateAwardReferenceList(arg1) {
|
export function UpdateAwardReferenceList(arg1) {
|
||||||
return window['go']['main']['App']['UpdateAwardReferenceList'](arg1);
|
return window['go']['main']['App']['UpdateAwardReferenceList'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1282,6 +1282,44 @@ export namespace main {
|
|||||||
this.my_pota_ref = source["my_pota_ref"];
|
this.my_pota_ref = source["my_pota_ref"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class UltrabeamSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new UltrabeamSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.port = source["port"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class UltrabeamStatusInfo {
|
||||||
|
enabled: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
direction: number;
|
||||||
|
frequency: number;
|
||||||
|
band: number;
|
||||||
|
moving: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new UltrabeamStatusInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.direction = source["direction"];
|
||||||
|
this.frequency = source["frequency"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.moving = source["moving"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class WKMacro {
|
export class WKMacro {
|
||||||
label: string;
|
label: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
|||||||
@@ -337,9 +337,14 @@ func (o *OmniRig) SetPTT(on bool) error {
|
|||||||
}
|
}
|
||||||
debugLog.Printf("OmniRig.SetPTT(%v): status=%d(%s) writeableParams=0x%X PM_TX-writeable=%v → Tx=%s",
|
debugLog.Printf("OmniRig.SetPTT(%v): status=%d(%s) writeableParams=0x%X PM_TX-writeable=%v → Tx=%s",
|
||||||
on, status, statusStr, writeable, txWriteable, name)
|
on, status, statusStr, writeable, txWriteable, name)
|
||||||
if on && !txWriteable {
|
// When OmniRig DID report its writeable params (writeable != -1) and PM_TX
|
||||||
debugLog.Printf("OmniRig.SetPTT: ⚠ this rig's OmniRig .ini does NOT expose TX keying (PM_TX not writeable). " +
|
// is NOT among them, writing Tx is a silent no-op: the rig never keys and
|
||||||
"Use VOX or serial RTS/DTR PTT instead.")
|
// SetPTT would otherwise return success, leaving the user puzzled ("Test PTT
|
||||||
|
// does nothing"). Surface a clear, actionable error instead. If we couldn't
|
||||||
|
// read the writeable params (-1), fall through and try anyway (best effort).
|
||||||
|
if on && writeable != -1 && writeable&pmTX == 0 {
|
||||||
|
debugLog.Printf("OmniRig.SetPTT: ⚠ PM_TX not writeable for this rig profile (writeableParams=0x%X)", writeable)
|
||||||
|
return fmt.Errorf("this rig's OmniRig profile doesn't expose CAT TX keying (PM_TX not writeable) — use RTS/DTR or VOX for PTT")
|
||||||
}
|
}
|
||||||
// OmniRig has NO SetTx method (that returns "unknown name"); the Tx
|
// OmniRig has NO SetTx method (that returns "unknown name"); the Tx
|
||||||
// parameter is set via the writeable Tx PROPERTY (PM_TX / PM_RX).
|
// parameter is set via the writeable Tx PROPERTY (PM_TX / PM_RX).
|
||||||
|
|||||||
+21
-18
@@ -1241,44 +1241,47 @@ type EntitySlot struct {
|
|||||||
Slots map[string]map[string]struct{} // band → modes worked
|
Slots map[string]map[string]struct{} // band → modes worked
|
||||||
}
|
}
|
||||||
|
|
||||||
// EntitySlotMap returns slot data for every QSO, grouping by entity.
|
// EntitySlotMap returns slot data for every QSO, grouped by DXCC entity NUMBER.
|
||||||
//
|
//
|
||||||
// `resolveEntity` maps a callsign to its canonical entity name (we use
|
// keyFor maps a QSO (its callsign + stored DXCC + stored country) to a DXCC
|
||||||
// cty.dat for this). When non-nil, the resolved name wins over the
|
// entity number. Keying by NUMBER — not name — is what makes the cluster
|
||||||
// stored `country` column — that's important because QRZ's "Turkey"
|
// "new / new-band / new-slot" check robust: QRZ's "Turkey" and cty.dat's
|
||||||
// disagrees with cty.dat's "Asiatic Turkey" and the cluster status
|
// "Asiatic Turkey" are the same entity (390), and a logged Lord Howe Island
|
||||||
// comparison would otherwise miss past QSOs. When nil, we fall back to
|
// QSO (stored DXCC 147) matches a VJ2L spot even though cty.dat resolves the
|
||||||
// the stored country (useful for tests).
|
// logged callsign "VK2/SP9FIH" to Australia by prefix. The caller decides the
|
||||||
|
// precedence (stored DXCC → stored country → cty.dat prefix). keyFor returning
|
||||||
|
// 0 (unresolvable) skips the QSO.
|
||||||
//
|
//
|
||||||
// One DB scan regardless of input size. Cheap to call per cluster batch.
|
// One DB scan regardless of input size. Cheap to call per cluster batch.
|
||||||
func (r *Repo) EntitySlotMap(ctx context.Context, resolveEntity func(callsign string) string) (map[string]*EntitySlot, error) {
|
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int) (map[int]*EntitySlot, error) {
|
||||||
rows, err := r.db.QueryContext(ctx,
|
rows, err := r.db.QueryContext(ctx,
|
||||||
`SELECT callsign, lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
|
`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
|
||||||
WHERE band IS NOT NULL AND band != ''
|
WHERE band IS NOT NULL AND band != ''
|
||||||
AND mode IS NOT NULL AND mode != ''`)
|
AND mode IS NOT NULL AND mode != ''`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
out := make(map[string]*EntitySlot, 256)
|
out := make(map[int]*EntitySlot, 256)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var call, country, band, mode string
|
var call, country, band, mode string
|
||||||
if err := rows.Scan(&call, &country, &band, &mode); err != nil {
|
var storedDXCC int
|
||||||
|
if err := rows.Scan(&call, &storedDXCC, &country, &band, &mode); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
key := country
|
key := 0
|
||||||
if resolveEntity != nil {
|
if keyFor != nil {
|
||||||
if name := strings.ToLower(strings.TrimSpace(resolveEntity(call))); name != "" {
|
key = keyFor(call, storedDXCC, country)
|
||||||
key = name
|
} else {
|
||||||
}
|
key = storedDXCC
|
||||||
}
|
}
|
||||||
if key == "" {
|
if key == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
e, ok := out[key]
|
e, ok := out[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
e = &EntitySlot{
|
e = &EntitySlot{
|
||||||
Country: key,
|
Country: country,
|
||||||
Bands: make(map[string]struct{}),
|
Bands: make(map[string]struct{}),
|
||||||
Slots: make(map[string]map[string]struct{}),
|
Slots: make(map[string]map[string]struct{}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
// Package ultrabeam drives an Ultrabeam remote-controlled antenna over TCP
|
||||||
|
// (typically via an RS232↔Ethernet adapter). The wire protocol (STX/ETX
|
||||||
|
// framing, DLE escaping, XOR checksum) and command codes are the manufacturer's.
|
||||||
|
package ultrabeam
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Protocol constants
|
||||||
|
const (
|
||||||
|
STX byte = 0xF5 // 245 decimal
|
||||||
|
ETX byte = 0xFA // 250 decimal
|
||||||
|
DLE byte = 0xF6 // 246 decimal
|
||||||
|
)
|
||||||
|
|
||||||
|
// Command codes
|
||||||
|
const (
|
||||||
|
CMD_STATUS byte = 1 // General status query
|
||||||
|
CMD_RETRACT byte = 2 // Retract elements
|
||||||
|
CMD_FREQ byte = 3 // Change frequency
|
||||||
|
CMD_READ_BANDS byte = 9 // Read current band adjustments
|
||||||
|
CMD_PROGRESS byte = 10 // Read progress bar
|
||||||
|
CMD_MODIFY_ELEM byte = 12 // Modify element length
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reply codes
|
||||||
|
const (
|
||||||
|
UB_OK byte = 0 // Normal execution
|
||||||
|
UB_BAD byte = 1 // Invalid command
|
||||||
|
UB_PAR byte = 2 // Bad parameters
|
||||||
|
UB_ERR byte = 3 // Error executing command
|
||||||
|
)
|
||||||
|
|
||||||
|
// Direction modes
|
||||||
|
const (
|
||||||
|
DIR_NORMAL byte = 0
|
||||||
|
DIR_180 byte = 1
|
||||||
|
DIR_BIDIR byte = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
conn net.Conn
|
||||||
|
connMu sync.Mutex
|
||||||
|
reader *bufio.Reader
|
||||||
|
lastStatus *Status
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
stopChan chan struct{}
|
||||||
|
running bool
|
||||||
|
seqNum byte
|
||||||
|
seqMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status struct {
|
||||||
|
FirmwareMinor int `json:"firmware_minor"`
|
||||||
|
FirmwareMajor int `json:"firmware_major"`
|
||||||
|
CurrentOperation int `json:"current_operation"`
|
||||||
|
Frequency int `json:"frequency"` // KHz
|
||||||
|
Band int `json:"band"`
|
||||||
|
Direction int `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
|
||||||
|
OffState bool `json:"off_state"`
|
||||||
|
MotorsMoving int `json:"motors_moving"` // Bitmask
|
||||||
|
FreqMin int `json:"freq_min"` // MHz
|
||||||
|
FreqMax int `json:"freq_max"` // MHz
|
||||||
|
ElementLengths []int `json:"element_lengths"` // mm
|
||||||
|
ProgressTotal int `json:"progress_total"` // mm
|
||||||
|
ProgressCurrent int `json:"progress_current"` // 0-60
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(host string, port int) *Client {
|
||||||
|
return &Client{
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
seqNum: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
c.running = true
|
||||||
|
go c.pollLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stopChan)
|
||||||
|
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) pollLoop() {
|
||||||
|
ticker := time.NewTicker(2 * time.Second) // Increased from 500ms to 2s
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
pollCount := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
pollCount++
|
||||||
|
|
||||||
|
// Try to connect if not connected
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn == nil {
|
||||||
|
log.Printf("Ultrabeam: Not connected, attempting connection...")
|
||||||
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Ultrabeam: Connection failed: %v", err)
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Mark as disconnected
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{Connected: false}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
c.reader = bufio.NewReader(c.conn)
|
||||||
|
log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port)
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Query status
|
||||||
|
status, err := c.queryStatus()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Ultrabeam: Failed to query status: %v", err)
|
||||||
|
// Close connection and retry
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
// Mark as disconnected
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{Connected: false}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as connected
|
||||||
|
status.Connected = true
|
||||||
|
|
||||||
|
// Query progress if motors moving
|
||||||
|
if status.MotorsMoving != 0 {
|
||||||
|
progress, err := c.queryProgress()
|
||||||
|
if err == nil {
|
||||||
|
status.ProgressTotal = progress[0]
|
||||||
|
status.ProgressCurrent = progress[1]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Motors stopped - reset progress
|
||||||
|
status.ProgressTotal = 0
|
||||||
|
status.ProgressCurrent = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = status
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() (*Status, error) {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
|
||||||
|
if c.lastStatus == nil {
|
||||||
|
return &Status{Connected: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.lastStatus, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNextSeq returns the next sequence number
|
||||||
|
func (c *Client) getNextSeq() byte {
|
||||||
|
c.seqMu.Lock()
|
||||||
|
defer c.seqMu.Unlock()
|
||||||
|
|
||||||
|
seq := c.seqNum
|
||||||
|
c.seqNum = (c.seqNum + 1) % 128
|
||||||
|
return seq
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateChecksum calculates the checksum for a packet
|
||||||
|
func calculateChecksum(data []byte) byte {
|
||||||
|
chk := byte(0x55)
|
||||||
|
for _, b := range data {
|
||||||
|
chk ^= b
|
||||||
|
chk++
|
||||||
|
}
|
||||||
|
return chk
|
||||||
|
}
|
||||||
|
|
||||||
|
// quoteByte handles DLE escaping
|
||||||
|
func quoteByte(b byte) []byte {
|
||||||
|
if b == STX || b == ETX || b == DLE {
|
||||||
|
return []byte{DLE, b & 0x7F} // Clear MSB
|
||||||
|
}
|
||||||
|
return []byte{b}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPacket creates a complete packet with checksum and escaping
|
||||||
|
func (c *Client) buildPacket(cmd byte, data []byte) []byte {
|
||||||
|
seq := c.getNextSeq()
|
||||||
|
|
||||||
|
// Calculate checksum on unquoted data
|
||||||
|
payload := append([]byte{seq, cmd}, data...)
|
||||||
|
chk := calculateChecksum(payload)
|
||||||
|
|
||||||
|
// Build packet with quoting
|
||||||
|
packet := []byte{STX}
|
||||||
|
|
||||||
|
// Add quoted SEQ
|
||||||
|
packet = append(packet, quoteByte(seq)...)
|
||||||
|
|
||||||
|
// Add quoted CMD
|
||||||
|
packet = append(packet, quoteByte(cmd)...)
|
||||||
|
|
||||||
|
// Add quoted data
|
||||||
|
for _, b := range data {
|
||||||
|
packet = append(packet, quoteByte(b)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add quoted checksum
|
||||||
|
packet = append(packet, quoteByte(chk)...)
|
||||||
|
|
||||||
|
// Add ETX
|
||||||
|
packet = append(packet, ETX)
|
||||||
|
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePacket parses a received packet, handling DLE unescaping
|
||||||
|
func parsePacket(data []byte) (seq byte, cmd byte, payload []byte, err error) {
|
||||||
|
if len(data) < 5 { // STX + SEQ + CMD + CHK + ETX
|
||||||
|
return 0, 0, nil, fmt.Errorf("packet too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data[0] != STX {
|
||||||
|
return 0, 0, nil, fmt.Errorf("missing STX")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data[len(data)-1] != ETX {
|
||||||
|
return 0, 0, nil, fmt.Errorf("missing ETX")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unquote the data
|
||||||
|
var unquoted []byte
|
||||||
|
dle := false
|
||||||
|
for i := 1; i < len(data)-1; i++ {
|
||||||
|
b := data[i]
|
||||||
|
if b == DLE {
|
||||||
|
dle = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dle {
|
||||||
|
b |= 0x80 // Set MSB
|
||||||
|
dle = false
|
||||||
|
}
|
||||||
|
unquoted = append(unquoted, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unquoted) < 3 {
|
||||||
|
return 0, 0, nil, fmt.Errorf("unquoted packet too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
seq = unquoted[0]
|
||||||
|
cmd = unquoted[1]
|
||||||
|
chk := unquoted[len(unquoted)-1]
|
||||||
|
payload = unquoted[2 : len(unquoted)-1]
|
||||||
|
|
||||||
|
// Verify checksum
|
||||||
|
calcChk := calculateChecksum(unquoted[:len(unquoted)-1])
|
||||||
|
if calcChk != chk {
|
||||||
|
return 0, 0, nil, fmt.Errorf("checksum mismatch: got %02X, expected %02X", chk, calcChk)
|
||||||
|
}
|
||||||
|
|
||||||
|
return seq, cmd, payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendCommand sends a command and waits for reply
|
||||||
|
func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
|
||||||
|
if c.conn == nil || c.reader == nil {
|
||||||
|
return nil, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and send packet
|
||||||
|
packet := c.buildPacket(cmd, data)
|
||||||
|
|
||||||
|
_, err := c.conn.Write(packet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reply with timeout
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) // Reduced from 2s to 1s
|
||||||
|
|
||||||
|
// Read until we get a complete packet
|
||||||
|
var buffer []byte
|
||||||
|
for {
|
||||||
|
b, err := c.reader.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer = append(buffer, b)
|
||||||
|
|
||||||
|
// Check if we have a complete packet
|
||||||
|
if b == ETX && len(buffer) > 0 && buffer[0] == STX {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent infinite loop
|
||||||
|
if len(buffer) > 256 {
|
||||||
|
return nil, fmt.Errorf("packet too long")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse reply
|
||||||
|
_, replyCmd, payload, err := parsePacket(buffer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse reply: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log for debugging unknown codes
|
||||||
|
if replyCmd != UB_OK && replyCmd != UB_BAD && replyCmd != UB_PAR && replyCmd != UB_ERR {
|
||||||
|
log.Printf("Ultrabeam: Unknown reply code %d (0x%02X), raw packet: %v", replyCmd, replyCmd, buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for errors
|
||||||
|
switch replyCmd {
|
||||||
|
case UB_BAD:
|
||||||
|
return nil, fmt.Errorf("invalid command")
|
||||||
|
case UB_PAR:
|
||||||
|
return nil, fmt.Errorf("bad parameters")
|
||||||
|
case UB_ERR:
|
||||||
|
return nil, fmt.Errorf("execution error")
|
||||||
|
case UB_OK:
|
||||||
|
return payload, nil
|
||||||
|
default:
|
||||||
|
// Unknown codes might indicate "busy" or "in progress"
|
||||||
|
// Treat as non-fatal, return empty payload
|
||||||
|
log.Printf("Ultrabeam: Unusual reply code %d, treating as busy/in-progress", replyCmd)
|
||||||
|
return []byte{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryStatus queries general status (command 1)
|
||||||
|
func (c *Client) queryStatus() (*Status, error) {
|
||||||
|
reply, err := c.sendCommand(CMD_STATUS, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reply) < 12 {
|
||||||
|
return nil, fmt.Errorf("status reply too short: %d bytes", len(reply))
|
||||||
|
}
|
||||||
|
|
||||||
|
status := &Status{
|
||||||
|
FirmwareMinor: int(reply[0]),
|
||||||
|
FirmwareMajor: int(reply[1]),
|
||||||
|
CurrentOperation: int(reply[2]),
|
||||||
|
Frequency: int(reply[3]) | (int(reply[4]) << 8),
|
||||||
|
Band: int(reply[5]),
|
||||||
|
Direction: int(reply[6] & 0x0F),
|
||||||
|
OffState: (reply[7] & 0x02) != 0,
|
||||||
|
MotorsMoving: int(reply[9]),
|
||||||
|
FreqMin: int(reply[10]),
|
||||||
|
FreqMax: int(reply[11]),
|
||||||
|
}
|
||||||
|
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryProgress queries motor progress (command 10)
|
||||||
|
func (c *Client) queryProgress() ([]int, error) {
|
||||||
|
reply, err := c.sendCommand(CMD_PROGRESS, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reply) < 4 {
|
||||||
|
return nil, fmt.Errorf("progress reply too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
total := int(reply[0]) | (int(reply[1]) << 8)
|
||||||
|
current := int(reply[2]) | (int(reply[3]) << 8)
|
||||||
|
|
||||||
|
return []int{total, current}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequency changes frequency and optional direction (command 3)
|
||||||
|
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||||
|
data := []byte{
|
||||||
|
byte(freqKhz & 0xFF),
|
||||||
|
byte((freqKhz >> 8) & 0xFF),
|
||||||
|
byte(direction),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.sendCommand(CMD_FREQ, data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDirection changes only the pattern direction (Normal / 180° / Bidirectional)
|
||||||
|
// by re-issuing the current frequency with the new direction byte — the device
|
||||||
|
// has no standalone direction command. Needs a status poll to have populated the
|
||||||
|
// current frequency first.
|
||||||
|
func (c *Client) SetDirection(direction int) error {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
freq := 0
|
||||||
|
if c.lastStatus != nil {
|
||||||
|
freq = c.lastStatus.Frequency
|
||||||
|
}
|
||||||
|
c.statusMu.RUnlock()
|
||||||
|
if freq <= 0 {
|
||||||
|
return fmt.Errorf("current frequency not known yet — wait for the antenna to report status")
|
||||||
|
}
|
||||||
|
return c.SetFrequency(freq, direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retract retracts all elements (command 2)
|
||||||
|
func (c *Client) Retract() error {
|
||||||
|
_, err := c.sendCommand(CMD_RETRACT, nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModifyElement modifies element length (command 12)
|
||||||
|
func (c *Client) ModifyElement(elementNum int, lengthMm int) error {
|
||||||
|
if elementNum < 0 || elementNum > 5 {
|
||||||
|
return fmt.Errorf("invalid element number: %d", elementNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := []byte{
|
||||||
|
byte(elementNum),
|
||||||
|
0, // Reserved
|
||||||
|
byte(lengthMm & 0xFF),
|
||||||
|
byte((lengthMm >> 8) & 0xFF),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.sendCommand(CMD_MODIFY_ELEM, data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user