Compare commits

..
11 Commits
Author SHA1 Message Date
rouggy 64b8ba7fdc chore: release v0.20.9 2026-07-22 20:24:34 +02:00
rouggy a9ca50b2dd fix: the Flex panel DAX button now toggles the TRANSMIT-bar DAX (transmit set dax=), not the slice RX DAX channel — this is the button that routes TX audio through DAX (WSJT-X); state follows changes made in SmartSDR too 2026-07-22 20:24:25 +02:00
rouggy 512b5fd35b feat: DAX selector on the Flex panel — routes the active slice's RX audio to a DAX channel (slice dax=0-8, Off..8 dropdown next to the antenna selection, highlighted when active); changelog updated 2026-07-22 17:33:45 +02:00
rouggy 77e3d011a0 ui: rebalance the Flex panel columns — RIT/XIT move to the bottom of the Transmit card, antenna RX/TX selection to the top of the Receive card (the Receive column had grown tall with the v4 DSP rows); changelog wording updated 2026-07-22 17:31:59 +02:00
rouggy b7dd8d4852 feat: multiple amplifiers + SmartSDR v4 DSP filters
Multi-amp: Settings->Amplifier becomes a list (amps.json, legacy single-amp keys auto-migrate to entry #1); one client per enabled amp with per-id bindings (GetAmplifiers/SaveAmplifiers/GetAmpStatuses/AmpOperate/AmpPower/AmpPowerLevel/AmpFanMode); legacy a.pgxl/a.spe/a.acom point at the first enabled amp of each family. Amp cards in FlexPanel and Station Control gain a dropdown to pick the amp; the status bar shows one clickable chip per amp. Use case: two SPEs run in parallel.

Flex v4 DSP (8000/Aurora): NRL/ANFL (lms_nr/lms_anf), NRS (speex_nr), NRF (nrf) with level sliders, RNN (rnnoise) and ANFT on/off — keys per the FlexLib slice docs; the section only shows when the radio reports these keys (dsp_v4 flag), so 6000-series panels are unchanged.
2026-07-22 17:25:10 +02:00
rouggy 4c75680689 feat: day batch — upload results surfaced as toasts (silent LoTW failures from the right-click send looked like nothing was sent); live selection count + Select all/Unselect all toggle in the grid toolbar; active filters bypass the row limit (all matches shown, 10k safety cap); NET Control: same right-click menu on worked-before + drag&drop roster<->on-air (drop on roster logs the QSO); compact mode restores previous window size/position; advanced-filter field renamed Station callsign; optional DXCC-style digital-mode grouping (Settings->General) for matrix badges + cluster colouring; changelog 0.20.9 entries (version NOT bumped) 2026-07-22 15:38:08 +02:00
rouggy 5aac28f564 fix: PGXL status-bar chip and Station Control widget read OPERATE from the Flex when it reports the amp (the direct GSCP status doesn't carry operate state) — toggle then goes through FlexAmpOperate like the Flex panel; direct TCP link stays as fallback; log distinct raw PGXL status payloads to map unknown fields 2026-07-22 09:33:39 +02:00
rouggy c91c8c3b47 feat: v0.20.8 — microHAM ARCO rotator native control (GS-232A over LAN TCP or USB serial, no PstRotator); amplifier OPERATE/STANDBY toggle on the status-bar chip for all three amps (PGXL direct operate command now wired + operate state parsed); PGXL operate button in the Station Control widget; NET Control auto-selects the next on-air station after logging and gains a 'Log everyone' button; changelog + version bump 2026-07-21 21:49:01 +02:00
rouggy 968da5488c chore: release v0.20.8 2026-07-21 21:48:23 +02:00
rouggy 1b5b0c2e90 feat: clicking the status-bar amplifier chip toggles OPERATE <-> STANDBY (SPE/ACOM, optimistic flip reconciled by the poll; disabled offline) — PGXL still opens Settings, it has no standby command 2026-07-21 18:54:48 +02:00
rouggy 0385aed760 feat: amplifier controls without a Flex/Icom panel — Station Control widget (SPE/ACOM full control, PGXL fan) + status-bar chip (green OPERATE / orange STANDBY / red offline, click opens Settings); warn with a toast when activating a profile whose MySQL is unreachable instead of silently staying on the old logbook 2026-07-21 18:47:31 +02:00
22 changed files with 1937 additions and 412 deletions
+1
View File
@@ -46,3 +46,4 @@ cat.log
.env.local .env.local
*.pem *.pem
*.key *.key
*.syso
+503 -47
View File
@@ -49,6 +49,7 @@ import (
"hamlog/internal/qslcard" "hamlog/internal/qslcard"
"hamlog/internal/qso" "hamlog/internal/qso"
"hamlog/internal/relaydev" "hamlog/internal/relaydev"
"hamlog/internal/rotator/gs232"
"hamlog/internal/rotator/pst" "hamlog/internal/rotator/pst"
"hamlog/internal/rotgenius" "hamlog/internal/rotgenius"
"hamlog/internal/settings" "hamlog/internal/settings"
@@ -158,8 +159,10 @@ const (
keyRotatorHost = "rotator.host" keyRotatorHost = "rotator.host"
keyRotatorPort = "rotator.port" keyRotatorPort = "rotator.port"
keyRotatorHasElevation = "rotator.has_elevation" keyRotatorHasElevation = "rotator.has_elevation"
keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, GS-232A)
keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2 keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2
keyRotatorTransport = "rotator.transport" // arco: "tcp" (LAN) | "serial" (USB virtual COM)
keyRotatorComPort = "rotator.com_port" // arco serial transport
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the // Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
// "ultrabeam." prefix for backward compatibility with configs saved before the // "ultrabeam." prefix for backward compatibility with configs saved before the
@@ -466,8 +469,10 @@ type App struct {
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
spe *spe.Client // SPE Expert amplifier (serial/TCP); nil when disabled or not SPE spe *spe.Client // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings)
acom *acom.Client // ACOM 500S/600S/700S/1200S/2020S amplifier (serial/TCP); nil when disabled or not ACOM acom *acom.Client // legacy pointer: FIRST enabled ACOM amp
ampsMu sync.Mutex // guards ampInsts
ampInsts map[string]*ampInst // one running client per enabled configured amplifier, by config ID
audioMgr *audio.Manager audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
@@ -527,6 +532,13 @@ type App struct {
// must be allowed through. // must be allowed through.
shuttingDown bool shuttingDown bool
compact bool // window is in the compact one-row mode (skip saving its tiny size as the normal geometry) compact bool // window is in the compact one-row mode (skip saving its tiny size as the normal geometry)
// Geometry captured when ENTERING compact mode, restored on the way back —
// otherwise leaving compact snapped the window to a hardcoded 1400×900 at
// wherever the compact strip had been dragged.
preCompactW, preCompactH int
preCompactX, preCompactY int
preCompactMax bool
preCompactValid bool
// Cached operator location used to compute distance/bearing for // Cached operator location used to compute distance/bearing for
// cluster spots. Refreshed on profile activation; zero means // cluster spots. Refreshed on profile activation; zero means
@@ -1063,7 +1075,7 @@ func (a *App) startup(ctx context.Context) {
// Antenna Genius switch: connect in the background if enabled. // Antenna Genius switch: connect in the background if enabled.
a.startAntGenius() a.startAntGenius()
// PowerGenius XL amp fan control: connect in the background if enabled. // PowerGenius XL amp fan control: connect in the background if enabled.
a.startPGXL() a.startAmps()
// Autostart: launch the active profile's configured external programs that // Autostart: launch the active profile's configured external programs that
// aren't already running (WSJT-X, JTAlert, rotator control, …). Background // aren't already running (WSJT-X, JTAlert, rotator control, …). Background
@@ -1808,6 +1820,17 @@ func (a *App) ResetDatabaseToDefault() error {
// widths, sort…) in the DB settings table under a "ui." namespace, so they // widths, sort…) in the DB settings table under a "ui." namespace, so they
// travel with the logbook and survive a reinstall — unlike the WebView's // travel with the logbook and survive a reinstall — unlike the WebView's
// localStorage. Values are opaque JSON blobs owned by the frontend. // localStorage. Values are opaque JSON blobs owned by the frontend.
// groupDigitalSlots reports the "digital modes count as one" preference
// (Settings → General; stored as a portable ui.* pref so the frontend matrix
// and this backend cluster logic read the same switch).
func (a *App) groupDigitalSlots() bool {
if a.settings == nil {
return false
}
v, _ := a.settings.Get(a.ctx, "ui.opslog.groupDigitalSlots")
return v == "1"
}
func (a *App) GetUIPref(key string) (string, error) { func (a *App) GetUIPref(key string) (string, error) {
if a.settings == nil { if a.settings == nil {
return "", nil return "", nil
@@ -5201,6 +5224,19 @@ func (a *App) SetCompactMode(on bool) {
if a.ctx == nil { if a.ctx == nil {
return return
} }
if on && !a.compact {
// Capture the normal geometry BEFORE shrinking, so leaving compact puts
// the window back exactly where and how big it was.
a.preCompactMax = wruntime.WindowIsMaximised(a.ctx)
if a.preCompactMax {
// Unmaximise first: the size/position of a maximised window are the
// monitor's, not the user's chosen geometry.
wruntime.WindowUnmaximise(a.ctx)
}
a.preCompactW, a.preCompactH = wruntime.WindowGetSize(a.ctx)
a.preCompactX, a.preCompactY = wruntime.WindowGetPosition(a.ctx)
a.preCompactValid = a.preCompactW >= normalMinW && a.preCompactH >= normalMinH
}
a.compact = on a.compact = on
if on { if on {
// Lock the window to the compact size by pinning min == max. Without // Lock the window to the compact size by pinning min == max. Without
@@ -5215,8 +5251,17 @@ func (a *App) SetCompactMode(on bool) {
// Release the lock first (raise the max) before growing back. // Release the lock first (raise the max) before growing back.
wruntime.WindowSetMaxSize(a.ctx, maxW, maxH) wruntime.WindowSetMaxSize(a.ctx, maxW, maxH)
wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH) wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH)
if a.preCompactValid {
wruntime.WindowSetSize(a.ctx, a.preCompactW, a.preCompactH)
wruntime.WindowSetPosition(a.ctx, a.preCompactX, a.preCompactY)
if a.preCompactMax {
wruntime.WindowMaximise(a.ctx)
}
} else {
// Nothing captured (compact since launch) — fall back to the default.
wruntime.WindowSetSize(a.ctx, normalW, normalH) wruntime.WindowSetSize(a.ctx, normalW, normalH)
} }
}
} }
// DeleteAllQSO wipes every QSO. Returns the number of rows removed. // DeleteAllQSO wipes every QSO. Returns the number of rows removed.
@@ -8157,6 +8202,12 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
msg = err.Error() msg = err.Error()
} }
emit("LoTW upload failed: " + msg) emit("LoTW upload failed: " + msg)
// The qslmgr:log console is only visible in the QSL Manager — a failure
// triggered from the Recent QSOs right-click was completely silent, which
// read as "send to LoTW does nothing". Surface it as a toast too.
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "toast", "LoTW upload failed: "+msg)
}
} else { } else {
for _, id := range ids { for _, id := range ids {
a.markExtUploaded(svc, id, "") a.markExtUploaded(svc, id, "")
@@ -8311,6 +8362,16 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
} }
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)}) wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)})
// Always end with a visible summary — the right-click "Send to …" runs in
// the background and the per-QSO log lives in the QSL Manager console.
label := map[extsvc.Service]string{
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
}[svc]
if label == "" {
label = string(svc)
}
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%s: %d/%d QSO uploaded", label, uploaded, len(ids)))
} }
} }
@@ -10836,6 +10897,86 @@ func (a *App) FlexSetWNBLevel(l int) error {
} }
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetWNBLevel(l) }) return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetWNBLevel(l) })
} }
// ── SmartSDR v4 DSP (8000/Aurora series): NRL / ANFL / NRS / RNN / ANFT / NRF ──
func (a *App) FlexSetLMSNR(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSNR(on) })
}
func (a *App) FlexSetLMSNRLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSNRLevel(l) })
}
func (a *App) FlexSetLMSANF(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSANF(on) })
}
func (a *App) FlexSetLMSANFLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSANFLevel(l) })
}
func (a *App) FlexSetSpeexNR(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSpeexNR(on) })
}
func (a *App) FlexSetSpeexNRLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSpeexNRLevel(l) })
}
func (a *App) FlexSetRNN(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRNN(on) })
}
func (a *App) FlexSetANFT(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANFT(on) })
}
func (a *App) FlexSetNRF(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNRF(on) })
}
func (a *App) FlexSetNRFLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNRFLevel(l) })
}
// FlexSetDAX routes the active slice's audio to a DAX channel (0 = off, 1-8).
func (a *App) FlexSetDAX(ch int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetDAX(ch) })
}
// FlexSetTXDAX toggles DAX as the transmit audio source — SmartSDR's
// transmit-bar DAX button (what WSJT-X TX audio goes through).
func (a *App) FlexSetTXDAX(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXDAX(on) })
}
func (a *App) FlexSetTXFilter(low, high int) error { func (a *App) FlexSetTXFilter(low, high int) error {
if a.cat == nil { if a.cat == nil {
return fmt.Errorf("cat not initialized") return fmt.Errorf("cat not initialized")
@@ -11214,7 +11355,14 @@ func (a *App) ActivateProfile(id int64) error {
// target (local SQLite or its own MySQL) so QSOs go to the right logbook. // target (local SQLite or its own MySQL) so QSOs go to the right logbook.
if p, err := a.profiles.Get(a.ctx, id); err == nil { if p, err := a.profiles.Get(a.ctx, id); err == nil {
if err := a.switchLogbook(p); err != nil { if err := a.switchLogbook(p); err != nil {
// The switch failed (typically: this profile's MySQL is unreachable right
// now) and the PREVIOUS logbook stays live. Silently staying on the old
// database is how QSOs end up in the wrong log — tell the operator.
applog.Printf("activate profile %d: logbook switch failed: %v", id, err) applog.Printf("activate profile %d: logbook switch failed: %v", id, err)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf(
"Profil %q : connexion à sa base impossible — l'ancienne base reste active ! (%v)", p.Name, err))
}
} }
} }
// Re-apply every settings-dependent subsystem for the new profile. // Re-apply every settings-dependent subsystem for the new profile.
@@ -11276,11 +11424,13 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error
// RotatorSettings is the JSON shape for the Hardware → Rotator panel. // RotatorSettings is the JSON shape for the Hardware → Rotator panel.
type RotatorSettings struct { type RotatorSettings struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, Yaesu GS-232A over TCP)
Host string `json:"host"` // default 127.0.0.1 Host string `json:"host"` // default 127.0.0.1
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius) Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius) / 4001 (arco — must match the port set in ARCO's LAN CONTROL PROTOCOL)
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator) HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2 RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2
Transport string `json:"transport"` // arco: "tcp" (LAN) | "serial" (USB virtual COM)
ComPort string `json:"com_port"` // arco serial transport
} }
// GetRotatorSettings returns the persisted rotator config with defaults. // GetRotatorSettings returns the persisted rotator config with defaults.
@@ -11290,12 +11440,13 @@ func (a *App) GetRotatorSettings() (RotatorSettings, error) {
return out, fmt.Errorf("db not initialized") return out, fmt.Errorf("db not initialized")
} }
m, err := a.settings.GetMany(a.ctx, m, err := a.settings.GetMany(a.ctx,
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum) keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum,
keyRotatorTransport, keyRotatorComPort)
if err != nil { if err != nil {
return out, err return out, err
} }
out.Enabled = m[keyRotatorEnabled] == "1" out.Enabled = m[keyRotatorEnabled] == "1"
if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" { if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" || t == "arco" {
out.Type = t out.Type = t
} }
if h := m[keyRotatorHost]; h != "" { if h := m[keyRotatorHost]; h != "" {
@@ -11305,11 +11456,18 @@ func (a *App) GetRotatorSettings() (RotatorSettings, error) {
out.Port = p out.Port = p
} else if out.Type == "rotgenius" { } else if out.Type == "rotgenius" {
out.Port = 9006 // native default when no port stored yet out.Port = 9006 // native default when no port stored yet
} else if out.Type == "arco" {
out.Port = 4001 // placeholder — the real number is set in ARCO's LAN menu
} }
out.HasElevation = m[keyRotatorHasElevation] == "1" out.HasElevation = m[keyRotatorHasElevation] == "1"
if rn, _ := strconv.Atoi(m[keyRotatorNum]); rn == 2 { if rn, _ := strconv.Atoi(m[keyRotatorNum]); rn == 2 {
out.RotatorNum = 2 out.RotatorNum = 2
} }
out.Transport = "tcp"
if m[keyRotatorTransport] == "serial" {
out.Transport = "serial"
}
out.ComPort = m[keyRotatorComPort]
return out, nil return out, nil
} }
@@ -11319,18 +11477,21 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
if a.settings == nil { if a.settings == nil {
return fmt.Errorf("db not initialized") return fmt.Errorf("db not initialized")
} }
if s.Type != "rotgenius" { if s.Type != "rotgenius" && s.Type != "arco" {
s.Type = "pst" s.Type = "pst"
} }
if s.Host == "" { if s.Host == "" {
s.Host = "127.0.0.1" s.Host = "127.0.0.1"
} }
if s.Port <= 0 || s.Port > 65535 { if s.Port <= 0 || s.Port > 65535 {
s.Port = map[string]int{"rotgenius": 9006, "pst": 12000}[s.Type] s.Port = map[string]int{"rotgenius": 9006, "pst": 12000, "arco": 4001}[s.Type]
} }
if s.RotatorNum != 2 { if s.RotatorNum != 2 {
s.RotatorNum = 1 s.RotatorNum = 1
} }
if s.Transport != "serial" {
s.Transport = "tcp"
}
for k, v := range map[string]string{ for k, v := range map[string]string{
keyRotatorEnabled: boolStr(s.Enabled), keyRotatorEnabled: boolStr(s.Enabled),
keyRotatorType: s.Type, keyRotatorType: s.Type,
@@ -11338,6 +11499,8 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
keyRotatorPort: strconv.Itoa(s.Port), keyRotatorPort: strconv.Itoa(s.Port),
keyRotatorHasElevation: boolStr(s.HasElevation), keyRotatorHasElevation: boolStr(s.HasElevation),
keyRotatorNum: strconv.Itoa(s.RotatorNum), keyRotatorNum: strconv.Itoa(s.RotatorNum),
keyRotatorTransport: s.Transport,
keyRotatorComPort: strings.TrimSpace(s.ComPort),
} { } {
if err := a.settings.Set(a.ctx, k, v); err != nil { if err := a.settings.Set(a.ctx, k, v); err != nil {
return err return err
@@ -11346,6 +11509,15 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
return nil return nil
} }
// arcoClient builds the GS-232 client for the configured ARCO transport:
// USB virtual COM (serial) or the LAN CONTROL PROTOCOL TCP port.
func arcoClient(s RotatorSettings) *gs232.Client {
if s.Transport == "serial" {
return gs232.NewSerial(s.ComPort)
}
return gs232.New(s.Host, s.Port)
}
// RotatorHeading is the live antenna heading for the status bar. // RotatorHeading is the live antenna heading for the status bar.
type RotatorHeading struct { type RotatorHeading struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@@ -11371,6 +11543,13 @@ func (a *App) GetRotatorHeading() RotatorHeading {
} }
return RotatorHeading{Enabled: true, OK: true, Azimuth: st.Azimuth, Raw: raw} return RotatorHeading{Enabled: true, OK: true, Azimuth: st.Azimuth, Raw: raw}
} }
if s.Type == "arco" {
az, raw, herr := arcoClient(s).Heading()
if herr != nil {
return RotatorHeading{Enabled: true, OK: false, Raw: herr.Error()}
}
return RotatorHeading{Enabled: true, OK: true, Azimuth: az, Raw: raw}
}
az, raw, herr := pst.New(s.Host, s.Port).Heading() az, raw, herr := pst.New(s.Host, s.Port).Heading()
if herr != nil { if herr != nil {
return RotatorHeading{Enabled: true, OK: false, Raw: raw} return RotatorHeading{Enabled: true, OK: false, Raw: raw}
@@ -11391,6 +11570,9 @@ func (a *App) RotatorGoTo(az int, el int) error {
if s.Type == "rotgenius" { if s.Type == "rotgenius" {
return rotgenius.New(s.Host, s.Port).GoTo(s.RotatorNum, az) return rotgenius.New(s.Host, s.Port).GoTo(s.RotatorNum, az)
} }
if s.Type == "arco" {
return arcoClient(s).GoTo(az)
}
return pst.New(s.Host, s.Port).GoTo(az, s.HasElevation, el) return pst.New(s.Host, s.Port).GoTo(az, s.HasElevation, el)
} }
@@ -11406,6 +11588,9 @@ func (a *App) RotatorStop() error {
if s.Type == "rotgenius" { if s.Type == "rotgenius" {
return rotgenius.New(s.Host, s.Port).Stop() return rotgenius.New(s.Host, s.Port).Stop()
} }
if s.Type == "arco" {
return arcoClient(s).Stop()
}
return pst.New(s.Host, s.Port).Stop() return pst.New(s.Host, s.Port).Stop()
} }
@@ -11423,6 +11608,9 @@ func (a *App) RotatorPark() error {
if s.Type == "rotgenius" { if s.Type == "rotgenius" {
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius") return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
} }
if s.Type == "arco" {
return fmt.Errorf("park is a PstRotator feature; not available over the ARCO GS-232 link")
}
return pst.New(s.Host, s.Port).Park() return pst.New(s.Host, s.Port).Park()
} }
@@ -11447,6 +11635,18 @@ func (a *App) TestRotator(s RotatorSettings) error {
// reply, so a rejected command surfaces as an error instead of a false OK. // reply, so a rejected command surfaces as an error instead of a false OK.
return rotgenius.New(s.Host, s.Port).GoTo(rn, 0) return rotgenius.New(s.Host, s.Port).GoTo(rn, 0)
} }
if s.Type == "arco" {
if s.Port <= 0 || s.Port > 65535 {
s.Port = 4001
}
if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" {
return fmt.Errorf("select the ARCO's COM port first")
}
// A heading query proves the link AND that the ARCO port really speaks
// GS-232 — without moving the antenna.
_, _, err := arcoClient(s).Heading()
return err
}
if s.Port <= 0 || s.Port > 65535 { if s.Port <= 0 || s.Port > 65535 {
s.Port = 12000 s.Port = 12000
} }
@@ -12341,53 +12541,288 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error {
return err return err
} }
} }
a.startPGXL() a.startAmps()
return nil return nil
} }
// startPGXL stops any existing client and starts a fresh one if enabled. // ── Multiple amplifiers ────────────────────────────────────────────────
func (a *App) startPGXL() { // Operators can run SEVERAL amps (even two SPEs combined for more power), so
if a.pgxl != nil { // the config is a LIST. The legacy single-amp keyPGXL* settings migrate into
// entry #1 the first time the list is read. Each enabled entry gets its own
// client; the legacy a.pgxl/a.spe/a.acom fields point at the FIRST enabled amp
// of each family so the pre-multi bindings (and the Flex-panel merge) keep
// working unchanged.
const keyAmpsList = "amps.json"
// AmpConfig is one configured amplifier in Settings → Amplifier.
type AmpConfig struct {
ID string `json:"id"`
Name string `json:"name"` // user label, e.g. "SPE left"
Enabled bool `json:"enabled"`
Type string `json:"type"` // "pgxl" | "spe13"|"spe15"|"spe2k" | "acom500"…"acom2020"
Transport string `json:"transport"` // "tcp" | "serial"
Host string `json:"host"`
Port int `json:"port"`
ComPort string `json:"com_port"`
Baud int `json:"baud"`
}
type ampInst struct {
cfg AmpConfig
pgxl *powergenius.Client
spe *spe.Client
acom *acom.Client
}
func (i *ampInst) stopAll() {
if i.pgxl != nil {
i.pgxl.Stop()
}
if i.spe != nil {
i.spe.Stop()
}
if i.acom != nil {
i.acom.Stop()
}
}
// ampTypeLabel is the default display name for an amp type.
func ampTypeLabel(t string) string {
switch {
case t == "" || t == "pgxl":
return "PowerGenius XL"
case strings.HasPrefix(t, "spe"):
return "SPE " + map[string]string{"spe13": "1.3K-FA", "spe15": "1.5K-FA", "spe2k": "2K-FA"}[t]
case strings.HasPrefix(t, "acom"):
return "ACOM " + strings.TrimPrefix(t, "acom") + "S"
}
return t
}
// GetAmplifiers returns the configured amplifier list. When none was ever
// saved, the legacy single-amp settings (if any) are presented as entry #1 —
// persisted on the next SaveAmplifiers.
func (a *App) GetAmplifiers() ([]AmpConfig, error) {
if a.settings == nil {
return nil, fmt.Errorf("db not initialized")
}
raw, _ := a.settings.Get(a.ctx, keyAmpsList)
if strings.TrimSpace(raw) != "" {
var list []AmpConfig
if err := json.Unmarshal([]byte(raw), &list); err == nil {
return list, nil
}
}
s, err := a.GetPGXLSettings()
if err != nil || (!s.Enabled && strings.TrimSpace(s.Host) == "" && strings.TrimSpace(s.ComPort) == "") {
return []AmpConfig{}, nil // never configured
}
return []AmpConfig{{
ID: "amp-1", Name: ampTypeLabel(s.Type), Enabled: s.Enabled,
Type: s.Type, Transport: s.Transport, Host: s.Host, Port: s.Port, ComPort: s.ComPort, Baud: s.Baud,
}}, nil
}
// SaveAmplifiers persists the amplifier list and (re)starts the clients.
func (a *App) SaveAmplifiers(list []AmpConfig) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
for i := range list {
c := &list[i]
if strings.TrimSpace(c.ID) == "" {
c.ID = fmt.Sprintf("amp-%d-%d", time.Now().Unix(), i)
}
if c.Type == "" {
c.Type = "pgxl"
}
if c.Type == "pgxl" || c.Transport != "serial" {
c.Transport = "tcp"
}
if c.Port <= 0 || c.Port > 65535 {
c.Port = 9008
}
if c.Baud <= 0 {
if strings.HasPrefix(c.Type, "acom") {
c.Baud = 9600
} else {
c.Baud = 115200
}
}
if strings.TrimSpace(c.Name) == "" {
c.Name = ampTypeLabel(c.Type)
}
}
b, err := json.Marshal(list)
if err != nil {
return err
}
if err := a.settings.Set(a.ctx, keyAmpsList, string(b)); err != nil {
return err
}
a.startAmps()
return nil
}
// startAmps stops every running amp client and starts one per enabled entry.
func (a *App) startAmps() {
a.ampsMu.Lock()
old := a.ampInsts
a.ampInsts = map[string]*ampInst{}
a.ampsMu.Unlock()
for _, inst := range old {
// Stop() can block up to the dial timeout waiting for an in-progress // Stop() can block up to the dial timeout waiting for an in-progress
// connect; tear down in the background so saving Settings (this runs on // connect; tear down in the background so saving Settings (this runs on
// the Wails RPC goroutine) doesn't freeze the UI. // the Wails RPC goroutine) doesn't freeze the UI.
go a.pgxl.Stop() go inst.stopAll()
a.pgxl = nil
} }
if a.spe != nil { a.pgxl, a.spe, a.acom = nil, nil, nil
go a.spe.Stop() list, err := a.GetAmplifiers()
a.spe = nil if err != nil {
}
if a.acom != nil {
go a.acom.Stop()
a.acom = nil
}
s, err := a.GetPGXLSettings()
if err != nil || !s.Enabled {
return return
} }
if s.Type == "" || s.Type == "pgxl" { for _, c := range list {
if strings.TrimSpace(s.Host) == "" { if !c.Enabled {
return continue
} }
a.pgxl = powergenius.New(s.Host, s.Port) isPGXL := c.Type == "" || c.Type == "pgxl"
_ = a.pgxl.Start() if !isPGXL && c.Transport == "serial" && strings.TrimSpace(c.ComPort) == "" {
return continue
} }
// SPE Expert / ACOM — USB serial or an RS232-to-Ethernet bridge. if (isPGXL || c.Transport == "tcp") && strings.TrimSpace(c.Host) == "" {
if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" { continue
return
} }
if s.Transport == "tcp" && strings.TrimSpace(s.Host) == "" { inst := &ampInst{cfg: c}
return switch {
case isPGXL:
inst.pgxl = powergenius.New(c.Host, c.Port)
_ = inst.pgxl.Start()
if a.pgxl == nil {
a.pgxl = inst.pgxl
} }
if model, ok := strings.CutPrefix(s.Type, "acom"); ok { case strings.HasPrefix(c.Type, "acom"):
a.acom = acom.New(acom.Config{Model: model + "S", Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port}) inst.acom = acom.New(acom.Config{Model: strings.TrimPrefix(c.Type, "acom") + "S", Transport: c.Transport, ComPort: c.ComPort, Baud: c.Baud, Host: c.Host, Port: c.Port})
_ = a.acom.Start() _ = inst.acom.Start()
return if a.acom == nil {
a.acom = inst.acom
} }
a.spe = spe.New(spe.Config{Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port}) default: // spe*
_ = a.spe.Start() inst.spe = spe.New(spe.Config{Transport: c.Transport, ComPort: c.ComPort, Baud: c.Baud, Host: c.Host, Port: c.Port})
_ = inst.spe.Start()
if a.spe == nil {
a.spe = inst.spe
}
}
a.ampsMu.Lock()
a.ampInsts[c.ID] = inst
a.ampsMu.Unlock()
}
}
// AmpStatus is one amp's live state for the UI poll — exactly one of the
// per-family payloads is set, per the amp's type.
type AmpStatus struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
PGXL *powergenius.Status `json:"pgxl,omitempty"`
SPE *spe.Status `json:"spe,omitempty"`
ACOM *acom.Status `json:"acom,omitempty"`
}
// GetAmpStatuses returns the live state of every ENABLED amplifier, in the
// configured order — one poll feeds every amp card and the status-bar chips.
func (a *App) GetAmpStatuses() []AmpStatus {
list, _ := a.GetAmplifiers()
a.ampsMu.Lock()
insts := a.ampInsts
a.ampsMu.Unlock()
out := []AmpStatus{}
for _, c := range list {
if !c.Enabled {
continue
}
st := AmpStatus{ID: c.ID, Name: c.Name, Type: c.Type}
if inst, ok := insts[c.ID]; ok {
switch {
case inst.pgxl != nil:
v := inst.pgxl.GetStatus()
st.PGXL = &v
case inst.spe != nil:
v := inst.spe.GetStatus()
st.SPE = &v
case inst.acom != nil:
v := inst.acom.GetStatus()
st.ACOM = &v
}
}
out = append(out, st)
}
return out
}
func (a *App) ampInstByID(id string) *ampInst {
a.ampsMu.Lock()
defer a.ampsMu.Unlock()
return a.ampInsts[id]
}
// AmpOperate puts the given amp in OPERATE (true) or STANDBY (false).
func (a *App) AmpOperate(id string, on bool) error {
inst := a.ampInstByID(id)
if inst == nil {
return fmt.Errorf("amplifier not running — check Settings → Amplifier")
}
switch {
case inst.pgxl != nil:
return inst.pgxl.SetOperate(on)
case inst.spe != nil:
return inst.spe.Operate(on)
case inst.acom != nil:
return inst.acom.Operate(on)
}
return fmt.Errorf("amplifier not running")
}
// AmpPower turns the given amp on/off (SPE and ACOM; the PGXL has no power
// command on its direct link).
func (a *App) AmpPower(id string, on bool) error {
inst := a.ampInstByID(id)
if inst == nil {
return fmt.Errorf("amplifier not running — check Settings → Amplifier")
}
switch {
case inst.spe != nil:
if on {
return inst.spe.PowerOn()
}
return inst.spe.PowerOff()
case inst.acom != nil:
if on {
return inst.acom.PowerOn()
}
return inst.acom.PowerOff()
}
return fmt.Errorf("power on/off is not available for this amplifier")
}
// AmpPowerLevel selects the output power level — SPE only (L/M/H).
func (a *App) AmpPowerLevel(id, level string) error {
inst := a.ampInstByID(id)
if inst == nil || inst.spe == nil {
return fmt.Errorf("power level is an SPE feature")
}
return inst.spe.SetPowerLevel(level)
}
// AmpFanMode sets the fan mode — PGXL only (STANDARD/CONTEST/BROADCAST).
func (a *App) AmpFanMode(id, mode string) error {
inst := a.ampInstByID(id)
if inst == nil || inst.pgxl == nil {
return fmt.Errorf("fan mode is a PowerGenius feature")
}
return inst.pgxl.SetFanMode(mode)
} }
// GetSPEStatus returns the SPE Expert amplifier state for the UI poll. // GetSPEStatus returns the SPE Expert amplifier state for the UI poll.
@@ -12463,6 +12898,15 @@ func (a *App) GetPGXLStatus() powergenius.Status {
return a.pgxl.GetStatus() return a.pgxl.GetStatus()
} }
// PGXLSetOperate puts the PowerGenius XL in OPERATE (true) or STANDBY (false)
// over its direct TCP link (no Flex needed).
func (a *App) PGXLSetOperate(on bool) error {
if a.pgxl == nil {
return fmt.Errorf("PowerGenius not connected — enable it in Settings → Amplifier")
}
return a.pgxl.SetOperate(on)
}
// PGXLSetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST). // PGXLSetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST).
func (a *App) PGXLSetFanMode(mode string) error { func (a *App) PGXLSetFanMode(mode string) error {
if a.pgxl == nil { if a.pgxl == nil {
@@ -12994,7 +13438,14 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
} }
return 0 return 0
} }
entities, err := a.qso.EntitySlotMap(a.ctx, keyFor) // Optional digital-mode grouping (Settings → General): with it on, FT8/FT4/
// RTTY… all count as ONE "DIG" mode, so an FT4 spot on a band where FT8 was
// worked shows "worked", not "new-slot" — DXCC-style mode classes.
var normMode func(string) string
if a.groupDigitalSlots() {
normMode = qso.GroupDigitalMode
}
entities, err := a.qso.EntitySlotMap(a.ctx, keyFor, normMode)
if err != nil { if err != nil {
return out return out
} }
@@ -13062,12 +13513,17 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
} }
// Band already worked. If this MODE was never worked on the entity (any // Band already worked. If this MODE was never worked on the entity (any
// band) → new-mode. If the mode was worked elsewhere but not on THIS // band) → new-mode. If the mode was worked elsewhere but not on THIS
// band+mode → new-slot. Otherwise → worked. // band+mode → new-slot. Otherwise → worked. The check mode goes through
if _, m := e.Modes[out[i].Mode]; !m { // the same normaliser as the slot map (digital grouping).
checkMode := out[i].Mode
if normMode != nil {
checkMode = normMode(checkMode)
}
if _, m := e.Modes[checkMode]; !m {
out[i].Status = "new-mode" out[i].Status = "new-mode"
continue continue
} }
if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok { if _, ok := e.Slots[out[i].Band][checkMode]; !ok {
out[i].Status = "new-slot" out[i].Status = "new-slot"
continue continue
} }
+50
View File
@@ -1,4 +1,54 @@
[ [
{
"version": "0.20.9",
"date": "2026-07-22",
"en": [
"The PGXL amplifier chip and Station Control widget now read the OPERATE state from the FlexRadio (like the Flex panel) — the chip no longer shows STANDBY while the amp is operating, and clicking it toggles the amp through the radio.",
"Right-click 'Send to LoTW / QRZ / Club Log…' now reports its result as a notification — a failed upload (e.g. TQSL not configured) was completely silent outside the QSL Manager, which looked like nothing was sent.",
"The number of selected QSOs is now always visible at the top of the log grid — no need to open the right-click menu to see it.",
"NET Control: the worked-before list gets the same right-click menu as Recent QSOs (update from cty/QRZ/Club Log, send to services, recording e-mail, eQSL, delete).",
"Leaving compact mode restores the window's previous size AND position (it used to snap to a fixed size wherever the compact strip had been dragged).",
"The advanced filter's 'My callsign' field is renamed 'Station callsign' so it is findable under its ADIF name (it filters STATION_CALLSIGN).",
"Filtering now shows EVERY match: the on-screen row limit (Max) only applies to the unfiltered log — a filter matching 200 QSOs displays all 200 even with Max at 100 (safety cap 10,000, with a warning to narrow the filter beyond that).",
"New 'Select all' button in the log grid toolbar — selects every displayed row (respecting active column filters) in one click, ready for send-to-LoTW, bulk edit or export; once everything is selected it flips to 'Unselect all'.",
"NET Control: drag & drop between the two lists — drag a roster station onto the on-air list to start its QSO, and drag an on-air station onto the roster to log it (same as the Log & end button).",
"New option (Settings → General): 'Group digital modes as one (DXCC-style)' — when on, the matrix newness badges and the cluster new/new-band/new-mode/new-slot colouring treat FT8/FT4/RTTY/PSK… as a single Digital mode, matching how DXCC counts; when off (default), each digital mode remains its own potential slot.",
"Multiple amplifiers: Settings → Amplifier is now a LIST — configure several amps (e.g. two SPEs run in parallel), each with its own name and connection. The amp cards in the Flex panel and Station Control get a dropdown to pick which amp they show, and the bottom status bar shows one clickable chip per amp. Existing single-amp setups migrate automatically.",
"FlexRadio panel: the SmartSDR v4 DSP filters are now controllable — NRL and ANFL (legacy LMS), NRS (spectral subtraction), NRF (noise reduction with filter) with their level sliders, plus RNN (AI noise reduction) and ANFT (FFT auto-notch) toggles. The section appears automatically on radios that support them (8000/Aurora series). Layout rebalanced: RIT/XIT moved to the Transmit column and the antenna selection now sits at the top of the Receive column, alongside a new DAX button (SmartSDR's transmit-bar DAX toggle — TX audio from DAX, for WSJT-X & co)."
],
"fr": [
"La pastille ampli PGXL et le widget Station Control lisent maintenant l'état OPERATE depuis le FlexRadio (comme le panneau Flex) — la pastille n'affiche plus STANDBY quand l'ampli est en service, et un clic bascule l'ampli via la radio.",
"Le clic droit « Send to LoTW / QRZ / Club Log… » affiche maintenant son résultat en notification — un envoi échoué (p. ex. TQSL non configuré) était totalement silencieux hors du QSL Manager, comme si rien n'était parti.",
"Le nombre de QSO sélectionnés est maintenant toujours visible en haut de la grille du log — plus besoin d'ouvrir le menu clic droit pour le voir.",
"NET Control : la liste worked-before dispose du même menu clic droit que QSO récents (mise à jour cty/QRZ/Club Log, envoi aux services, e-mail d'enregistrement, eQSL, suppression).",
"Quitter le mode compact restaure la taille ET la position précédentes de la fenêtre (avant, elle reprenait une taille fixe là où la barre compacte avait été déplacée).",
"Le champ « Mon indicatif » du filtre avancé est renommé « Station callsign » pour être trouvable sous son nom ADIF (il filtre STATION_CALLSIGN).",
"Le filtrage affiche maintenant TOUTES les correspondances : la limite d'affichage (Max) ne s'applique qu'au log non filtré — un filtre qui matche 200 QSO les affiche tous les 200 même avec Max à 100 (plafond de sécurité 10 000, avec un avertissement pour affiner au-delà).",
"Nouveau bouton « Tout sélectionner » dans la barre d'outils de la grille — sélectionne toutes les lignes affichées (en respectant les filtres de colonnes actifs) en un clic, prêt pour l'envoi LoTW, le bulk edit ou l'export ; une fois tout sélectionné il devient « Tout désélectionner ».",
"NET Control : glisser-déposer entre les deux listes — glisser une station du roster vers la liste on air démarre son QSO, et glisser une station on air vers le roster l'enregistre au log (comme le bouton Logger & terminer).",
"Nouvelle option (Réglages → Général) : « Regrouper les modes digitaux en un seul (style DXCC) » — activée, les badges de nouveauté de la matrice et le coloriage cluster new/new-band/new-mode/new-slot traitent FT8/FT4/RTTY/PSK… comme un seul mode Digital, comme le DXCC ; désactivée (défaut), chaque mode digital reste un slot potentiel distinct.",
"Plusieurs amplificateurs : Réglages → Amplificateur est maintenant une LISTE — configurez plusieurs amplis (p. ex. deux SPE en parallèle), chacun avec son nom et sa connexion. Les cartes ampli du panneau Flex et de Station Control ont une liste déroulante pour choisir lequel afficher, et la barre du bas montre une pastille cliquable par ampli. Les configurations mono-ampli existantes migrent automatiquement.",
"Panneau FlexRadio : les filtres DSP SmartSDR v4 sont maintenant pilotables — NRL et ANFL (LMS legacy), NRS (soustraction spectrale), NRF (réduction de bruit avec filtre) avec leurs curseurs de niveau, plus les interrupteurs RNN (réduction de bruit par IA) et ANFT (notch automatique FFT). La section apparaît automatiquement sur les radios qui les supportent (séries 8000/Aurora). Mise en page rééquilibrée : RIT/XIT passent dans la colonne Transmit et la sélection d'antennes s'affiche en haut de la colonne Receive, avec un nouveau bouton DAX (le toggle DAX du bandeau transmit de SmartSDR — audio d'émission via DAX, pour WSJT-X & co)."
]
},
{
"version": "0.20.8",
"date": "2026-07-21",
"en": [
"New: microHAM ARCO rotator controlled natively — no PstRotator needed. Over the LAN or over USB: set the ARCO's CONTROL PROTOCOL (LAN or USB) to 'Yaesu GS-232A' and pick 'microHAM ARCO' in Settings → Rotator.",
"Amplifier without a FlexRadio: the amplifier controls (OPERATE/STANDBY, ON/OFF, live power/SWR/temperature) now also live in the Station Control tab, and a status chip sits in the bottom bar next to Cluster/CAT/Rotator — green ON AIR = OPERATE, orange = STANDBY, red = offline. Clicking the chip toggles OPERATE/STANDBY on all three amps (PowerGenius XL included, over its direct network link).",
"NET Control: after logging a station, the next on-air station is selected automatically (chain log → log → log), and a new 'Log everyone' button logs every on-air station at once when the net ends.",
"Switching to a profile whose MySQL database is unreachable now shows a clear warning — before, OpsLog silently stayed on the previous logbook and QSOs could land in the wrong log.",
"The ON AIR badge is back for local (SQLite) logbooks — it had disappeared with the removal of the live-status option."
],
"fr": [
"Nouveau : rotator microHAM ARCO contrôlé en natif — sans PstRotator. Par le réseau ou par USB : régler le CONTROL PROTOCOL de l'ARCO (LAN ou USB) sur « Yaesu GS-232A » et choisir « microHAM ARCO » dans Réglages → Rotator.",
"Amplificateur sans FlexRadio : les commandes d'ampli (OPERATE/STANDBY, ON/OFF, puissance/ROS/température en direct) sont maintenant aussi dans l'onglet Station Control, et une pastille de statut s'affiche dans la barre du bas à côté de Cluster/CAT/Rotator — vert = OPERATE, orange = STANDBY, rouge = hors ligne. Un clic sur la pastille bascule OPERATE/STANDBY sur les trois amplis (PowerGenius XL compris, via son lien réseau direct).",
"NET Control : après avoir loggé une station, la station on air suivante est sélectionnée automatiquement (enchaînement log → log → log), et un nouveau bouton « Logger tout le monde » enregistre toutes les stations on air d'un coup à la fin du net.",
"Passer sur un profil dont la base MySQL est injoignable affiche maintenant un avertissement clair — avant, OpsLog restait silencieusement sur l'ancien logbook et les QSO pouvaient partir dans le mauvais log.",
"Le badge ON AIR est de retour pour les logbooks locaux (SQLite) — il avait disparu avec la suppression de l'option de statut en direct."
]
},
{ {
"version": "0.20.7", "version": "0.20.7",
"date": "2026-07-21", "date": "2026-07-21",
+87 -9
View File
@@ -43,6 +43,8 @@ import {
GetAwardDefs, GetAwardDefs,
GetUIPref, GetActiveProfile, QuitApp, GetUIPref, GetActiveProfile, QuitApp,
ReportLiveActivity, LiveLastQSOAgeSec, ReportLiveActivity, LiveLastQSOAgeSec,
GetAmpStatuses, AmpOperate,
GetFlexState, FlexAmpOperate,
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs'; import { applyAwardRefs } from '@/lib/awardRefs';
@@ -114,6 +116,9 @@ type ModePreset = ModePresetForm;
type WB = WorkedBeforeView; type WB = WorkedBeforeView;
type CATState = Omit<catModels.RigState, 'convertValues'>; type CATState = Omit<catModels.RigState, 'convertValues'>;
// Safety cap on how many rows an ACTIVE filter may load (the normal row
// threshold is bypassed while filtering — see buildActiveFilter).
const FILTERED_LIMIT_CAP = 10000;
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm']; const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE']; const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in // Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
@@ -436,6 +441,22 @@ export default function App() {
// click reverts the UI and the click looks like it did nothing. // click reverts the UI and the click looks like it did nothing.
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({}); const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null); const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
// Amplifier chips in the status bar — ONE PER configured amp (several are
// possible: some ops run two SPEs in parallel). Green OPERATE / orange
// STANDBY / red offline; click toggles. The Flex state rides along because a
// PGXL's OPERATE state comes from the radio when it reports the amp.
const [ampSts, setAmpSts] = useState<any[]>([]);
const [flexAmp, setFlexAmp] = useState<any>(null);
useEffect(() => {
let alive = true;
const tick = () => Promise.all([
GetAmpStatuses().catch(() => []),
GetFlexState().catch(() => null),
]).then(([l, fx]: any[]) => { if (alive) { setAmpSts((l ?? []) as any[]); setFlexAmp(fx); } });
tick();
const id = window.setInterval(tick, 2000);
return () => { alive = false; window.clearInterval(id); };
}, []);
// Multi-op "who's on air" widget: every operator's live status from the shared // Multi-op "who's on air" widget: every operator's live status from the shared
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook. // MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number }; type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
@@ -1390,13 +1411,21 @@ export default function App() {
// The full filter sent to the backend = the builder conditions + the quick // The full filter sent to the backend = the builder conditions + the quick
// callsign search box (always ANDed) + the on-screen row threshold. // callsign search box (always ANDed) + the on-screen row threshold.
const buildActiveFilter = useCallback((): QueryFilter => ({ // When a filter is ACTIVE the row threshold is lifted to a high safety cap:
// the threshold exists to keep the UNFILTERED 30k-QSO log fast, but applying
// it to a filter meant "200 matches, only the first 100 shown". A filtered
// result set is almost always small, and the cap protects against a
// broad filter (e.g. band=20m) pulling half the log.
const buildActiveFilter = useCallback((): QueryFilter => {
const hasFilter = !!(filterCallsign || (activeFilter.conditions ?? []).length);
return {
quick_callsign: filterCallsign, quick_callsign: filterCallsign,
conditions: activeFilter.conditions ?? [], conditions: activeFilter.conditions ?? [],
match: activeFilter.match ?? 'AND', match: activeFilter.match ?? 'AND',
limit: qsoLimit, limit: hasFilter ? FILTERED_LIMIT_CAP : qsoLimit,
offset: 0, offset: 0,
}), [filterCallsign, activeFilter, qsoLimit]); };
}, [filterCallsign, activeFilter, qsoLimit]);
// refresh reloads the grid. Returns false on failure. When silent, it doesn't // refresh reloads the grid. Returns false on failure. When silent, it doesn't
// surface the error — used by the startup retry, since the logbook DB (a remote // surface the error — used by the startup retry, since the logbook DB (a remote
@@ -3688,7 +3717,12 @@ export default function App() {
case 'netcontrol': case 'netcontrol':
return ( return (
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border"> <div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} /> <NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
qsoMenuHandlers={{
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
onDelete: (ids: number[]) => setDeletingIds(ids),
}} />
</div> </div>
); );
case 'recent': case 'recent':
@@ -4755,9 +4789,13 @@ export default function App() {
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{qsos.length >= qsoLimit && qsos.length < total && ( {(activeFilter.conditions?.length || filterCallsign)
? (matchCount != null && matchCount > FILTERED_LIMIT_CAP && (
<span className="text-warning">{matchCount.toLocaleString('en-US')} matches only {FILTERED_LIMIT_CAP.toLocaleString('en-US')} shown, narrow the filter (or use the filtered ADIF export).</span>
))
: (qsos.length >= qsoLimit && qsos.length < total && (
<span className="text-warning">Limit reached raise Max to see more.</span> <span className="text-warning">Limit reached raise Max to see more.</span>
)} ))}
<Label className="text-[11px] text-muted-foreground">Max</Label> <Label className="text-[11px] text-muted-foreground">Max</Label>
<Input <Input
type="number" type="number"
@@ -5011,7 +5049,12 @@ export default function App() {
tune the rig. */} tune the rig. */}
{netEnabled && ( {netEnabled && (
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1"> <TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} /> <NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
qsoMenuHandlers={{
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
onDelete: (ids: number[]) => setDeletingIds(ids),
}} />
</TabsContent> </TabsContent>
)} )}
@@ -5122,7 +5165,43 @@ export default function App() {
disabled={!rotatorHeading.enabled} disabled={!rotatorHeading.enabled}
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }} onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
/> />
{liveStationsOn && ( {/* Amplifier chips one per configured amp: green = OPERATE, orange =
STANDBY, red = offline. CLICK toggles OPERATE STANDBY (optimistic
flip, the 2s poll reconciles); offline click opens the settings. */}
{ampSts.map((a: any) => {
const isPGXL = !a.spe && !a.acom;
const viaFlex = isPGXL && !!flexAmp?.amp_available;
const raw = a.spe ?? a.acom ?? a.pgxl ?? { connected: false };
const connected = !!raw.connected || viaFlex;
const operate = viaFlex ? !!flexAmp.amp_operate : !!raw.operate;
const dot = !connected ? 'bg-danger' : operate ? 'bg-success' : 'bg-warning';
const state = !connected ? (raw.last_error || 'offline') : operate ? 'OPERATE' : 'STANDBY';
const toggle = () => {
if (!connected) { setSettingsSection('pgxl'); setShowSettings(true); return; }
const want = !operate;
if (viaFlex) setFlexAmp((f: any) => ({ ...f, amp_operate: want }));
else setAmpSts((l) => l.map((x: any) => x.id === a.id
? { ...x, spe: x.spe && { ...x.spe, operate: want }, acom: x.acom && { ...x.acom, operate: want }, pgxl: x.pgxl && { ...x.pgxl, operate: want } }
: x));
(viaFlex ? FlexAmpOperate(want) : AmpOperate(a.id, want)).catch(() => {});
};
return (
<button
key={a.id}
type="button"
title={`${a.name}: ${state}${connected ? (operate ? ' — click for STANDBY' : ' — click for OPERATE') : ' — click for settings'}`}
onClick={toggle}
className="inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors border-border hover:bg-muted cursor-pointer"
>
<span className={cn('size-2 rounded-full', dot)} />
{a.name}
</button>
);
})}
{/* ON AIR badge: "did I log a QSO in the last 5 min" meaningful on ANY
logbook backend (only the live_status PUBLISHING is MySQL-specific),
so it is always shown. Gating it on MySQL made it vanish for
local-SQLite operators when the Settings toggle was removed. */}
<div <div
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors', className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')} onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
@@ -5131,7 +5210,6 @@ export default function App() {
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} /> <span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
{onAir ? t('live.onAir') : t('live.offline')} {onAir ? t('live.onAir') : t('live.offline')}
</div> </div>
)}
{/* Toasts / errors: the status bar's free space is far wider than the {/* Toasts / errors: the status bar's free space is far wider than the
header band they used to sit in. Still one line (the bar is 28px), header band they used to sit in. Still one line (the bar is 28px),
but CLICK opens the full text wrapped long messages (a TQSL or but CLICK opens the full text wrapped long messages (a TQSL or
+13 -6
View File
@@ -109,14 +109,21 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
// "Newness" of the current band+mode entry, for the award/DX-chase badges. // "Newness" of the current band+mode entry, for the award/DX-chase badges.
// Derived straight from the entity's real band_status (all bands it was // Derived straight from the entity's real band_status (all bands it was
// worked on — not just the operator's configured column list). // worked on — not just the operator's configured column list).
// Newness uses the ACTUAL mode (FT8 / FT4 / RTTY…), not the PH/CW/DIG class: // By default newness uses the ACTUAL mode (FT8 / FT4 / RTTY…): DIG is a
// DIG is a group, so FT4 after FT8 is genuinely a new mode. dxcc_band_modes // group, so FT4 after FT8 is genuinely a new mode. The operator can opt into
// lists every real (band, mode) the entity was worked on. // DXCC-style grouping instead (Settings → General), where all digital modes
// count as ONE — then FT4 after FT8 is just "worked".
const groupDigital = localStorage.getItem('opslog.groupDigitalSlots') === '1';
const normMode = (m: string): string => {
const u = (m || '').toUpperCase().trim();
if (!groupDigital) return u;
return u === '' || u === 'CW' || PHONE_MODES.has(u) ? u : 'DIG';
};
const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[]; const bandModes = (wb?.dxcc_band_modes ?? []) as { band: string; mode: string }[];
const curMode = (currentMode || '').toUpperCase().trim(); const curMode = normMode(currentMode);
const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode) const bandWorked = bandModes.some((bm) => bm.band === currentBand); // entity worked on this band (any mode)
const modeWorked = !!curMode && bandModes.some((bm) => (bm.mode || '').toUpperCase() === curMode); // …in this exact mode (any band) const modeWorked = !!curMode && bandModes.some((bm) => normMode(bm.mode) === curMode); // …in this (normalised) mode (any band)
const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && (bm.mode || '').toUpperCase() === curMode); const slotWorked = !!curMode && bandModes.some((bm) => bm.band === currentBand && normMode(bm.mode) === curMode);
// Mutually-exclusive badges, shown only when the entity is worked but this // Mutually-exclusive badges, shown only when the entity is worked but this
// exact band+mode is NOT yet: // exact band+mode is NOT yet:
// New Band & Mode = both the band AND the mode are new for this entity. // New Band & Mode = both the band AND the mode are new for this entity.
+107 -54
View File
@@ -4,11 +4,13 @@ import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay, GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate, FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode, GetPGXLSettings, GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel, GetPGXLStatus, PGXLSetFanMode,
GetACOMStatus, ACOMSetOperate, ACOMSetPower, GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq, FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
FlexSetSpeexNR, FlexSetSpeexNRLevel, FlexSetRNN, FlexSetANFT, FlexSetNRF, FlexSetNRFLevel, FlexSetTXDAX,
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile, FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter, FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
@@ -31,6 +33,11 @@ type FlexState = {
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number; rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
wnb: boolean; wnb_level: number; wnb: boolean; wnb_level: number;
// SmartSDR v4 DSP (8000/Aurora) — dsp_v4 is true once the radio reports them.
lms_nr?: boolean; lms_nr_level?: number; lms_anf?: boolean; lms_anf_level?: number;
speex_nr?: boolean; speex_nr_level?: number; rnn?: boolean; anft?: boolean;
nrf?: boolean; nrf_level?: number; dsp_v4?: boolean;
dax_ch?: number; tx_dax?: boolean;
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[]; tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
mode?: string; mode?: string;
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number; cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
@@ -342,39 +349,31 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
return () => { alive = false; window.clearInterval(id); }; return () => { alive = false; window.clearInterval(id); };
}, []); }, []);
// Configured amplifier type (pgxl / spe*), so we show the SPE control when the // Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops
// operator runs an SPE Expert instead of the PowerGenius. // run two SPEs in parallel). The card shows ONE at a time; the dropdown picks
const [ampType, setAmpType] = useState<string>('pgxl'); // which, and the choice is remembered per panel.
const [ampEnabled, setAmpEnabled] = useState(false); const [ampList, setAmpList] = useState<any[]>([]);
const [ampSel, setAmpSel] = useState<string>(() => localStorage.getItem('opslog.ampSel.flex') || '');
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
const load = () => GetPGXLSettings().then((s: any) => { if (alive) { setAmpType(s?.type || 'pgxl'); setAmpEnabled(!!s?.enabled); } }).catch(() => {}); const tick = () => GetAmpStatuses().then((l: any) => alive && setAmpList((l ?? []) as any[])).catch(() => {});
load(); tick();
const id = window.setInterval(load, 5000); const id = window.setInterval(tick, 1500);
return () => { alive = false; window.clearInterval(id); }; return () => { alive = false; window.clearInterval(id); };
}, []); }, []);
const isACOM = ampEnabled && ampType.startsWith('acom'); const selAmp = ampList.find((a: any) => a.id === ampSel) ?? ampList[0];
const isSPE = ampEnabled && !isACOM && ampType !== 'pgxl'; const isSPE = !!selAmp?.spe;
// SPE Expert live status (only polled when an SPE amp is configured). const isACOM = !!selAmp?.acom;
const [spe, setSpe] = useState<any>({ connected: false }); const spe = selAmp?.spe ?? { connected: false };
useEffect(() => { const acom = selAmp?.acom ?? { connected: false };
if (!isSPE) return; const ampPicker = ampList.length > 1 && selAmp ? (
let alive = true; <select value={selAmp.id}
const tick = () => GetSPEStatus().then((s: any) => alive && setSpe(s || { connected: false })).catch(() => {}); onChange={(e) => { setAmpSel(e.target.value); localStorage.setItem('opslog.ampSel.flex', e.target.value); }}
tick(); title={t('flxp.ampPick')}
const id = window.setInterval(tick, 1500); className="h-8 rounded-md border border-border bg-card px-2 text-xs font-semibold outline-none">
return () => { alive = false; window.clearInterval(id); }; {ampList.map((a: any) => <option key={a.id} value={a.id}>{a.name}</option>)}
}, [isSPE]); </select>
// ACOM live status (only polled when an ACOM amp is configured). ) : null;
const [acom, setAcom] = useState<any>({ connected: false });
useEffect(() => {
if (!isACOM) return;
let alive = true;
const tick = () => GetACOMStatus().then((s: any) => alive && setAcom(s || { connected: false })).catch(() => {});
tick();
const id = window.setInterval(tick, 1500);
return () => { alive = false; window.clearInterval(id); };
}, [isACOM]);
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => { const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
hold.current[key] = Date.now() + 900; hold.current[key] = Date.now() + 900;
@@ -702,12 +701,24 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} /> onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
</div> </div>
)} )}
{/* RIT/XIT live on the TRANSMIT side to balance the two columns — the
RECEIVE card grew tall with the v4 DSP rows. */}
<div className="space-y-1.5 border-t border-border/60 pt-3">
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
</div>
</Card> </Card>
{/* RECEIVE */} {/* RECEIVE */}
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2"> <Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2">
{/* Antenna selection sits at the very top of the RX column. */}
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && ( {((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 pb-3 border-b border-border/60">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
<div className="flex items-center gap-1.5 flex-1 min-w-0"> <div className="flex items-center gap-1.5 flex-1 min-w-0">
<span className="text-[10px] text-muted-foreground">RX</span> <span className="text-[10px] text-muted-foreground">RX</span>
@@ -722,21 +733,17 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40"> className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)} {((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
</select> </select>
{/* DAX on/off — SmartSDR's transmit-bar DAX button (TX audio from
DAX, e.g. WSJT-X). "transmit set dax=", not the slice channel. */}
<button type="button" disabled={off} title={t('flxp.daxHint')}
onClick={() => change('tx_dax', !st.tx_dax, () => FlexSetTXDAX(!st.tx_dax))}
className={cn('h-7 px-2.5 shrink-0 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-40',
st.tx_dax ? 'bg-info text-info-foreground border-info' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
DAX
</button>
</div> </div>
</div> </div>
)} )}
{/* RIT / XIT — on the RECEIVE side: RIT is what you reach for while
listening (chasing a station that drifts off your transmit frequency),
so it belongs with the other things you touch mid-QSO. Both act on the
ACTIVE slice and follow slice focus like every control here. */}
<div className="space-y-1.5 pb-3 border-b border-border/60">
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff} <Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
@@ -777,6 +784,49 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} /> onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
)} )}
</div> </div>
{/* SmartSDR v4 DSP (8000/Aurora series) — NRL/ANFL (legacy LMS), NRS
(spectral subtraction), NRF (NR w/ filter), RNN (AI NR), ANFT (FFT
notch). Only shown when the radio actually reports these slice keys
(older 6000s never do). API keys per the FlexLib slice docs. */}
{st.dsp_v4 && (
<div className="border-t border-border/60 pt-3 space-y-3">
<LevelRow label="NRL" on={!!st.lms_nr} disabled={rxOff} value={st.lms_nr_level ?? 0} accent="cyan" sliderAccent="#0e7490"
onToggle={() => change('lms_nr', !st.lms_nr, () => FlexSetLMSNR(!st.lms_nr))}
onLevel={(v) => change('lms_nr_level', v, () => FlexSetLMSNRLevel(v))} />
<LevelRow label="NRS" on={!!st.speex_nr} disabled={rxOff} value={st.speex_nr_level ?? 0} accent="cyan" sliderAccent="#06b6d4"
onToggle={() => change('speex_nr', !st.speex_nr, () => FlexSetSpeexNR(!st.speex_nr))}
onLevel={(v) => change('speex_nr_level', v, () => FlexSetSpeexNRLevel(v))} />
<LevelRow label="NRF" on={!!st.nrf} disabled={rxOff} value={st.nrf_level ?? 0} accent="cyan" sliderAccent="#0369a1"
onToggle={() => change('nrf', !st.nrf, () => FlexSetNRF(!st.nrf))}
onLevel={(v) => change('nrf_level', v, () => FlexSetNRFLevel(v))} />
{/* Notch filters target carriers in voice — hidden in CW like ANF. */}
{!isCW && (
<LevelRow label="ANFL" on={!!st.lms_anf} disabled={rxOff} value={st.lms_anf_level ?? 0} accent="violet" sliderAccent="#8b5cf6"
onToggle={() => change('lms_anf', !st.lms_anf, () => FlexSetLMSANF(!st.lms_anf))}
onLevel={(v) => change('lms_anf_level', v, () => FlexSetLMSANFLevel(v))} />
)}
{/* RNN and ANFT are on/off only — no level in the API. */}
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground" title={t('flxp.dspV4Hint')}>AI/FFT</span>
<button type="button" disabled={rxOff}
title={t('flxp.rnnHint')}
onClick={() => change('rnn', !st.rnn, () => FlexSetRNN(!st.rnn))}
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
st.rnn ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
RNN
</button>
{!isCW && (
<button type="button" disabled={rxOff}
title={t('flxp.anftHint')}
onClick={() => change('anft', !st.anft, () => FlexSetANFT(!st.anft))}
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
st.anft ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
ANFT
</button>
)}
</div>
</div>
)}
{isCW && ( {isCW && (
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a" <LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
@@ -830,10 +880,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
SPE link rather than the Flex amplifier object. */} SPE link rather than the Flex amplifier object. */}
{isSPE && ( {isSPE && (
<Card icon={Flame} title={`${t('flxp.amplifier')} · SPE${spe.model ? ' ' + spe.model : ''}`} accent="#ea580c"> <Card icon={Flame} title={`${t('flxp.amplifier')} · ${selAmp?.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
{ampPicker}
<button type="button" disabled={!spe.connected} <button type="button" disabled={!spe.connected}
onClick={() => SPESetOperate(!spe.operate).catch(() => {})} onClick={() => AmpOperate(selAmp.id, !spe.operate).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30', className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}> spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{spe.operate ? 'OPERATE' : 'STANDBY'} {spe.operate ? 'OPERATE' : 'STANDBY'}
@@ -841,10 +892,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */} {/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70"> <div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!spe.connected} <button type="button" disabled={!spe.connected}
onClick={() => SPESetPower(true).catch(() => {})} onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button> className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
<button type="button" disabled={!spe.connected} <button type="button" disabled={!spe.connected}
onClick={() => SPESetPower(false).catch(() => {})} onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button> className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
</div> </div>
{/* Output power level: Low / Mid / High. */} {/* Output power level: Low / Mid / High. */}
@@ -853,7 +904,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
const active = (spe.power_level || '').trim().toUpperCase() === lvl; const active = (spe.power_level || '').trim().toUpperCase() === lvl;
return ( return (
<button key={lvl} type="button" disabled={!spe.connected} <button key={lvl} type="button" disabled={!spe.connected}
onClick={() => SPESetPowerLevel(lvl).catch(() => {})} onClick={() => AmpPowerLevel(selAmp.id, lvl).catch(() => {})}
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border', className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}> active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{powerLevelLabel(lvl)} {powerLevelLabel(lvl)}
@@ -887,10 +938,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven {/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven
by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */} by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */}
{isACOM && ( {isACOM && (
<Card icon={Flame} title={`${t('flxp.amplifier')} · ACOM${acom.model ? ' ' + acom.model : ''}`} accent="#ea580c"> <Card icon={Flame} title={`${t('flxp.amplifier')} · ${selAmp?.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
{ampPicker}
<button type="button" disabled={!acom.connected} <button type="button" disabled={!acom.connected}
onClick={() => ACOMSetOperate(!acom.operate).catch(() => {})} onClick={() => AmpOperate(selAmp.id, !acom.operate).catch(() => {})}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30', className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
acom.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}> acom.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{acom.operate ? 'OPERATE' : 'STANDBY'} {acom.operate ? 'OPERATE' : 'STANDBY'}
@@ -899,11 +951,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
is off (the port stays open), so gate on port_open not connected. */} is off (the port stays open), so gate on port_open not connected. */}
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70"> <div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')} <button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
onClick={() => ACOMSetPower(true).catch(() => {})} onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'} title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'}
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button> className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
<button type="button" disabled={!acom.connected} <button type="button" disabled={!acom.connected}
onClick={() => ACOMSetPower(false).catch(() => {})} onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button> className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
</div> </div>
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}> <span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
@@ -935,6 +987,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{st.amp_available && !isSPE && !isACOM && ( {st.amp_available && !isSPE && !isACOM && (
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c"> <Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{ampPicker}
<button type="button" disabled={off} <button type="button" disabled={off}
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))} onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30', className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
@@ -947,7 +1000,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* Fan mode — shown when the PowerGenius is configured (Settings → {/* Fan mode — shown when the PowerGenius is configured (Settings →
PowerGenius). The dot shows the direct-connection state; the PowerGenius). The dot shows the direct-connection state; the
selector is disabled until connected (hover it for the error). */} selector is disabled until connected (hover it for the error). */}
{ampType === 'pgxl' && (pg.host || pg.connected) && ( {selAmp?.type === 'pgxl' && (pg.host || pg.connected) && (
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}> <label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} /> <span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-muted-foreground">{t('flxp.fan')}</span> <span className="text-muted-foreground">{t('flxp.fan')}</span>
+87 -6
View File
@@ -42,14 +42,28 @@ function fmtTimeOn(s: any): string {
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' }); const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
// The right-click actions of the main Recent QSOs grid (update from cty/QRZ/
// Club Log, send to services, recording e-mail, eQSL, delete) — passed down so
// the worked-before grid here offers the SAME context menu on logged QSOs.
type QSOMenuHandlers = {
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void;
onDelete?: (ids: number[]) => void;
};
type Props = { type Props = {
onLogged?: () => void; onLogged?: () => void;
countries?: string[]; countries?: string[];
bands?: string[]; bands?: string[];
modes?: string[]; modes?: string[];
qsoMenuHandlers?: QSOMenuHandlers;
}; };
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) { export function NetControlPanel({ onLogged, countries, bands, modes, qsoMenuHandlers }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const [nets, setNets] = useState<Net[]>([]); const [nets, setNets] = useState<Net[]>([]);
const [selId, setSelId] = useState<string>(''); const [selId, setSelId] = useState<string>('');
@@ -61,6 +75,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses). // Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null); const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]); const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
// Programmatic row selection in the on-air grid (auto-advance after logging).
const [selectRow, setSelectRow] = useState<{ id: number; seq: number }>({ id: 0, seq: 0 });
// Add/edit-contact dialog. // Add/edit-contact dialog.
const [contactOpen, setContactOpen] = useState(false); const [contactOpen, setContactOpen] = useState(false);
@@ -68,6 +84,34 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
const [looking, setLooking] = useState(false); const [looking, setLooking] = useState(false);
const rosterGrid = useRef<any>(null); const rosterGrid = useRef<any>(null);
// Cross-grid drag & drop: roster row → on-air grid puts the station on air;
// on-air row → roster grid logs it (same as the "Log & end" button). AG-Grid
// external drop zones need each grid's api + the OTHER grid's container div.
const onAirApi = useRef<any>(null);
const onAirWrap = useRef<HTMLDivElement | null>(null);
const rosterWrap = useRef<HTMLDivElement | null>(null);
const dropZonesDone = useRef({ roster: false, onair: false });
// Callbacks live in refs so the drop-zone closures (registered once) always
// call the CURRENT activate/deactivate, not a stale first-render one.
const activateRef = useRef<(call: string) => void>(() => {});
const deactivateRef = useRef<(id?: number) => void>(() => {});
const wireDropZones = useCallback(() => {
const rApi = rosterGrid.current?.api;
if (rApi && onAirWrap.current && !dropZonesDone.current.roster) {
dropZonesDone.current.roster = true;
rApi.addRowDropZone({
getContainer: () => onAirWrap.current!,
onDragStop: (p: any) => { const call = p.node?.data?.callsign; if (call) activateRef.current(call); },
});
}
if (onAirApi.current && rosterWrap.current && !dropZonesDone.current.onair) {
dropZonesDone.current.onair = true;
onAirApi.current.addRowDropZone({
getContainer: () => rosterWrap.current!,
onDragStop: (p: any) => { const id = p.node?.data?.id; if (id != null) deactivateRef.current(id); },
});
}
}, []);
// Worked-before for the clicked station (see if/when we contacted it before). // Worked-before for the clicked station (see if/when we contacted it before).
const [wbCall, setWbCall] = useState(''); const [wbCall, setWbCall] = useState('');
@@ -177,10 +221,38 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
catch (e: any) { setError(String(e?.message ?? e)); } catch (e: any) { setError(String(e?.message ?? e)); }
} }
// Active → logged (end QSO, removed from session, written to the logbook). // Active → logged (end QSO, removed from session, written to the logbook).
// Then auto-select the FIRST remaining on-air station, so the operator can
// chain "log → log → log" without re-clicking a row each time.
async function deactivate(id?: number) { async function deactivate(id?: number) {
if (id == null) return; if (id == null) return;
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); } const next = active.find((a) => a.id !== id); // computed BEFORE the list shrinks
catch (e: any) { setError(String(e?.message ?? e)); } try {
await NetDeactivate(id);
await refreshActive();
onLogged?.();
if (next?.id != null) {
setSelectRow((s) => ({ id: next.id as number, seq: s.seq + 1 }));
setSelectedActiveIds([next.id as number]);
showWorkedBefore(next.callsign, (next as any).dxcc ?? 0);
}
} catch (e: any) { setError(String(e?.message ?? e)); }
}
// Keep the drop-zone closures pointed at the CURRENT handlers.
activateRef.current = activate;
deactivateRef.current = deactivate;
// Log EVERYONE still on air (end of net): each draft is written to the logbook
// exactly as the single-log button would.
async function logAll() {
if (active.length === 0) return;
if (!window.confirm(t('ncp.logAllConfirm', { n: active.length }))) return;
try {
for (const a of [...active]) {
if (a.id != null) await NetDeactivate(a.id as number);
}
await refreshActive();
onLogged?.();
} catch (e: any) { setError(String(e?.message ?? e)); await refreshActive(); }
} }
// Edit-modal handlers (operate on the in-memory draft, not the DB). // Edit-modal handlers (operate on the in-memory draft, not the DB).
@@ -232,7 +304,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
} }
const rosterCols = useMemo<ColDef<Station>[]>(() => [ const rosterCols = useMemo<ColDef<Station>[]>(() => [
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' }, // rowDrag: drag a roster station onto the on-air grid to start its QSO.
{ headerName: t('ncp.colCallsign'), field: 'callsign', width: 110, cellClass: 'font-mono font-semibold', rowDrag: true },
{ headerName: t('ncp.colName'), field: 'name', flex: 1 }, { headerName: t('ncp.colName'), field: 'name', flex: 1 },
{ headerName: 'QTH', field: 'qth', flex: 1 }, { headerName: 'QTH', field: 'qth', flex: 1 },
{ headerName: t('ncp.colCountry'), field: 'country', width: 130 }, { headerName: t('ncp.colCountry'), field: 'country', width: 130 },
@@ -281,11 +354,14 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
{t('ncp.onAirActive')} {t('ncp.onAirActive')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span> <span className="ml-auto font-normal normal-case opacity-90">{t('ncp.activeHint')}</span>
</div> </div>
<div className="flex flex-col min-h-0 flex-1"> <div ref={onAirWrap} className="flex flex-col min-h-0 flex-1">
<RecentQSOsGrid <RecentQSOsGrid
rows={active} rows={active}
total={active.length} total={active.length}
storageKey="net.onair" storageKey="net.onair"
selectRowSignal={selectRow}
rowDragCall
onGridApi={(api) => { onAirApi.current = api; wireDropZones(); }}
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)} onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
onRowDoubleClicked={(q) => setEditingDraft(q)} onRowDoubleClicked={(q) => setEditingDraft(q)}
onRowSelected={setSelectedActiveIds} onRowSelected={setSelectedActiveIds}
@@ -297,6 +373,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}> onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')} <MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
</Button> </Button>
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto" onClick={logAll}>
<MinusCircle className="size-3.5" /> {t('ncp.logAll', { n: active.length })}
</Button>
</div> </div>
)} )}
@@ -330,6 +409,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
rows={(wb.entries ?? []) as any} rows={(wb.entries ?? []) as any}
total={wb.count} total={wb.count}
storageKey="net.wb" storageKey="net.wb"
{...qsoMenuHandlers}
/> />
)} )}
</div> </div>
@@ -343,7 +423,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
{t('ncp.netUsersRoster')} {t('ncp.netUsersRoster')}
<span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span> <span className="ml-auto font-normal normal-case opacity-90">{t('ncp.rosterHint')}</span>
</div> </div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}> <div ref={rosterWrap} style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}> <div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<Station> <AgGridReact<Station>
ref={rosterGrid} ref={rosterGrid}
@@ -352,6 +432,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
columnDefs={rosterCols} columnDefs={rosterCols}
defaultColDef={defaultColDef} defaultColDef={defaultColDef}
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }} rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onGridReady={() => wireDropZones()}
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)} onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)} onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
animateRows={false} animateRows={false}
+65 -5
View File
@@ -5,7 +5,7 @@ import {
} from 'ag-grid-community'; } from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme'; import { hamlogGridTheme } from '@/lib/gridTheme';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
import { Columns3, FilterX } from 'lucide-react'; import { Columns3, FilterX, ListChecks } from 'lucide-react';
import type { QSOForm } from '@/types'; import type { QSOForm } from '@/types';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu'; import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import { import {
@@ -30,6 +30,14 @@ type Props = {
// Bump this number to programmatically select every row (e.g. after a search // Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected"). // in the QSL Manager, where the default is "all selected").
selectAllSignal?: number; selectAllSignal?: number;
// Bump `seq` to programmatically select the single row with this id (e.g. NET
// Control auto-advancing to the next on-air station after logging one).
selectRowSignal?: { id: number; seq: number };
// Show a row-drag handle on the callsign column (NET Control: drag an on-air
// row onto the roster to log it). Pair with onGridApi so the parent can
// register external drop zones via api.addRowDropZone.
rowDragCall?: boolean;
onGridApi?: (api: any) => void;
// storageKey scopes the persisted column layout/visibility. Omit for the main // storageKey scopes the persisted column layout/visibility. Omit for the main
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to // log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
// reuse this grid elsewhere with its OWN independent column config. // reuse this grid elsewhere with its OWN independent column config.
@@ -249,7 +257,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] => const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_')); (st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) { export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
@@ -257,6 +265,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
// (e.g. the Net panel) keeps its own layout independent of the main log. // (e.g. the Net panel) keeps its own layout independent of the main log.
const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY; const colStateKey = storageKey ? `hamlog.qsoColState.${storageKey}` : BASE_COLSTATE_KEY;
const [menu, setMenu] = useState<QSOMenuState>(null); const [menu, setMenu] = useState<QSOMenuState>(null);
const [selCount, setSelCount] = useState(0); // live selection count shown in the toolbar
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
// Localized column catalog — rebuilt when the language changes. // Localized column catalog — rebuilt when the language changes.
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]); const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
@@ -316,7 +326,11 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
restoringRef.current = true; restoringRef.current = true;
const base = COL_CATALOG.map((c) => { const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c; const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible }; const col: ColDef<QSOForm> = { ...rest, hide: !defaultVisible };
if (rowDragCall && ((rest as any).colId === 'callsign' || (rest as any).field === 'callsign')) {
col.rowDrag = true;
}
return col;
}); });
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({ const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
colId: `award_${a.code}`, colId: `award_${a.code}`,
@@ -330,7 +344,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '', valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
})); }));
return [...base, ...awards]; return [...base, ...awards];
}, [awardCols, COL_CATALOG, t, awardShown]); }, [awardCols, COL_CATALOG, t, awardShown, rowDragCall]);
// Enforce award-column visibility via the API after the columns (re)appear — // Enforce award-column visibility via the API after the columns (re)appear —
// colDef.hide alone isn't honoured by AG Grid for a column that already exists, // colDef.hide alone isn't honoured by AG Grid for a column that already exists,
@@ -353,6 +367,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
}), []); }), []);
function onGridReady(e: GridReadyEvent) { function onGridReady(e: GridReadyEvent) {
onGridApi?.(e.api);
const local = loadLocal(colStateKey); const local = loadLocal(colStateKey);
if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true }); if (local) e.api.applyColumnState({ state: stripAwardCols(local) as ColumnState[], applyOrder: true });
// Fall back to the portable DB copy when the local cache is empty // Fall back to the portable DB copy when the local cache is empty
@@ -368,6 +383,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
// null when no column filter is active, so "Showing X of Y" reflects them. // null when no column filter is active, so "Showing X of Y" reflects them.
const reportFilteredCount = useCallback((e: { api?: any }) => { const reportFilteredCount = useCallback((e: { api?: any }) => {
const api = e?.api ?? gridRef.current?.api; const api = e?.api ?? gridRef.current?.api;
if (api?.getDisplayedRowCount) setDispCount(api.getDisplayedRowCount());
if (!api || !onFilteredCountChange) return; if (!api || !onFilteredCountChange) return;
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null); onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
}, [onFilteredCountChange]); }, [onFilteredCountChange]);
@@ -397,7 +413,10 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data); if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
} }
function onSelectionChanged() { function onSelectionChanged() {
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? []; const api = gridRef.current?.api;
const sel = (api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
setSelCount(sel.length);
setDispCount(api?.getDisplayedRowCount?.() ?? 0);
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null)); onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
} }
// Select every row when the caller bumps selectAllSignal (QSL Manager search). // Select every row when the caller bumps selectAllSignal (QSL Manager search).
@@ -406,6 +425,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
gridRef.current?.api?.selectAll(); gridRef.current?.api?.selectAll();
}, [selectAllSignal]); }, [selectAllSignal]);
// Select ONE row by id when the caller bumps selectRowSignal.seq. Deferred a
// tick so a row-data refresh in the same render settles first.
useEffect(() => {
if (!selectRowSignal || selectRowSignal.seq === 0) return;
const t = window.setTimeout(() => {
const api = gridRef.current?.api;
if (!api) return;
api.deselectAll();
api.forEachNode((n: any) => {
if (n.data?.id === selectRowSignal.id) n.setSelected(true);
});
}, 0);
return () => window.clearTimeout(t);
}, [selectRowSignal?.seq]);
// ── Column picker (visibility) ── // ── Column picker (visibility) ──
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React // Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
// state for "which columns are visible" — AG Grid's column state is the // state for "which columns are visible" — AG Grid's column state is the
@@ -462,6 +496,32 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
return ( return (
<> <>
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20"> <div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
{/* Live selection count — visible without opening the context menu. */}
{selCount > 0 && (
<span className="mr-auto text-[11px] font-semibold text-primary tabular-nums px-1.5">
{t('rqg.selectedCount', { n: selCount })}
</span>
)}
{/* Select every loaded row that passes the active column filters — so a
filtered view can be selected in one click (then send to LoTW, bulk
edit, export…). Once everything is selected the same button flips to
"Unselect all". */}
{(() => {
const allSelected = selCount > 0 && dispCount > 0 && selCount >= dispCount;
return (
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
title={allSelected ? t('rqg.unselectAllTitle') : t('rqg.selectAllTitle')}
onClick={() => {
const api: any = gridRef.current?.api;
if (!api) return;
if (allSelected) api.deselectAll();
else if (typeof api.selectAllFiltered === 'function') api.selectAllFiltered();
else api.selectAll('filtered');
}}>
<ListChecks className="size-3.5" /> {allSelected ? t('rqg.unselectAll') : t('rqg.selectAll')}
</Button>
);
})()}
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)} <Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
title={t('rqg.clearFiltersTitle')}> title={t('rqg.clearFiltersTitle')}>
<FilterX className="size-3.5" /> {t('rqg.clearFilters')} <FilterX className="size-3.5" /> {t('rqg.clearFilters')}
+137 -86
View File
@@ -13,7 +13,7 @@ import {
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop, GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetAntGeniusSettings, SaveAntGeniusSettings, GetAntGeniusSettings, SaveAntGeniusSettings,
GetPGXLSettings, SavePGXLSettings, GetSPEStatus, SPESetOperate, GetACOMStatus, ACOMSetOperate, GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT, GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty, GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
@@ -620,6 +620,9 @@ function ADIFMonitorPanel() {
); );
} }
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number };
// RelayAutoPanel configures automatic control of the Station Control relay boards // RelayAutoPanel configures automatic control of the Station Control relay boards
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule: // from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
// off, a frequency window (kHz), or a set of bands. // off, a frequency window (kHz), or a set of bands.
@@ -628,33 +631,38 @@ type StationDevUI = { id: string; type: string; name: string; labels: string[] }
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm']; const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5); const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
// Live SPE Expert amplifier status + OPERATE/STANDBY toggle. Module-scoped (not a // Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
// nested component) so it isn't remounted on every parent render. Polls once a // id) — SPE / ACOM / PGXL alike. Module-scoped (not a nested component) so it
// second while shown. // isn't remounted on every parent render. Polls once a second while shown.
function SPEStatusCard() { function AmpStatusCard({ id }: { id: string }) {
const [st, setSt] = useState<any>({ connected: false }); const [amp, setAmp] = useState<any>(null);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
const tick = () => GetSPEStatus().then((s) => alive && setSt(s || {})).catch(() => {}); const tick = () => GetAmpStatuses().then((all: any[]) => {
if (alive) setAmp((all ?? []).find((a) => a.id === id) ?? null);
}).catch(() => {});
tick(); tick();
const id = window.setInterval(tick, 1000); const t = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(id); }; return () => { alive = false; window.clearInterval(t); };
}, []); }, [id]);
const st: any = amp?.spe ?? amp?.acom ?? amp?.pgxl ?? { connected: false };
const isSPE = !!amp?.spe;
const isACOM = !!amp?.acom;
const operate = !!st.operate; const operate = !!st.operate;
return ( return (
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl"> <div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} /> <span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
<span className="font-semibold">{st.connected ? `SPE ${st.model || 'Expert'}` : 'SPE — not connected'}</span> <span className="font-semibold">{amp?.name || 'Amplifier'}{st.connected ? '' : ' — not connected'}</span>
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>} {!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
<span className="flex-1" /> <span className="flex-1" />
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected} <Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
onClick={() => SPESetOperate(!operate).catch(() => {})} onClick={() => AmpOperate(id, !operate).catch(() => {})}
title="Toggle OPERATE / STANDBY"> title="Toggle OPERATE / STANDBY">
{operate ? 'OPERATE' : 'STANDBY'} {operate ? 'OPERATE' : 'STANDBY'}
</Button> </Button>
</div> </div>
{st.connected && ( {st.connected && isSPE && (
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]"> <div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
<div>{st.tx ? 'TX' : 'RX'}</div> <div>{st.tx ? 'TX' : 'RX'}</div>
<div>Band {st.band}</div> <div>Band {st.band}</div>
@@ -667,35 +675,7 @@ function SPEStatusCard() {
{(st.warnings || st.alarms) && <div className="col-span-4 text-warning"> {st.warnings} {st.alarms}</div>} {(st.warnings || st.alarms) && <div className="col-span-4 text-warning"> {st.warnings} {st.alarms}</div>}
</div> </div>
)} )}
</div> {st.connected && isACOM && (
);
}
// Live ACOM amplifier status + OPERATE/STANDBY. Module-scoped for the same
// remount reason as SPEStatusCard. Polls once a second while shown.
function ACOMStatusCard() {
const [st, setSt] = useState<any>({ connected: false });
useEffect(() => {
let alive = true;
const tick = () => GetACOMStatus().then((s) => alive && setSt(s || {})).catch(() => {});
tick();
const id = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(id); };
}, []);
const operate = !!st.operate;
return (
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
<div className="flex items-center gap-2">
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
<span className="font-semibold">{st.connected ? `ACOM ${st.model || ''}` : `ACOM ${st.model || ''} — not connected`}</span>
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
<span className="flex-1" />
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
onClick={() => ACOMSetOperate(!operate).catch(() => {})}
title="Toggle OPERATE / STANDBY">
{operate ? 'OPERATE' : 'STANDBY'}
</Button>
</div>
{st.connected && (
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]"> <div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
<div>{st.state}</div> <div>{st.state}</div>
<div>Band {st.band || '—'}</div> <div>Band {st.band || '—'}</div>
@@ -708,6 +688,13 @@ function ACOMStatusCard() {
{st.err_text && <div className="col-span-4 text-warning"> {st.err_text} ({st.err_code})</div>} {st.err_text && <div className="col-span-4 text-warning"> {st.err_text} ({st.err_code})</div>}
</div> </div>
)} )}
{st.connected && !isSPE && !isACOM && (
<div className="grid grid-cols-3 gap-x-3 gap-y-1 font-mono text-[11px]">
<div>{st.state || ''}</div>
<div>Fan {st.fan_mode || '—'}</div>
<div>{st.temperature ? `${Math.round(st.temperature)}°C` : ''}</div>
</div>
)}
</div> </div>
); );
} }
@@ -1076,9 +1063,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007. // Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' }); const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
// Amplifier control settings (PowerGenius XL over TCP; SPE Expert over serial/IP). // Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
const [pgxl, setPgxl] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number }>( // each with its own connection. Saved as a whole via SaveAmplifiers.
{ enabled: false, type: 'pgxl', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200 }); const [amps, setAmps] = useState<AmpUI[]>([]);
// WinKeyer CW keyer settings + macro editor. // WinKeyer CW keyer settings + macro editor.
type WKMac = { label: string; text: string }; type WKMac = { label: string; text: string };
@@ -1149,6 +1136,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1'); const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1'); const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1'); const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1'); const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
// Password-encryption (secret vault) state. // Password-encryption (secret vault) state.
@@ -1395,7 +1383,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setRotator(r); setRotator(r);
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {} try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {} try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setPgxl(await GetPGXLSettings() as any); } catch {} try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } 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);
@@ -1435,7 +1423,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
try { setRotator(await GetRotatorSettings() as any); } catch {} try { setRotator(await GetRotatorSettings() as any); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {} try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {} try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setPgxl(await GetPGXLSettings() as any); } catch {} try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
try { setBackupCfg(await GetBackupSettings() as any); } catch {} try { setBackupCfg(await GetBackupSettings() as any); } catch {}
try { setQslDefaults(await GetQSLDefaults() as any); } catch {} try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
try { setExtSvc(await GetExternalServices() as any); } catch {} try { setExtSvc(await GetExternalServices() as any); } catch {}
@@ -1604,7 +1592,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
await SaveRotatorSettings(rotator as any); await SaveRotatorSettings(rotator as any);
await SaveUltrabeamSettings(ultrabeam as any); await SaveUltrabeamSettings(ultrabeam as any);
await SaveAntGeniusSettings(antgenius as any); await SaveAntGeniusSettings(antgenius as any);
await SavePGXLSettings(pgxl as any); await SaveAmplifiers(amps 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);
@@ -2709,12 +2697,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
} }
function PGXLPanelSettings() { function PGXLPanelSettings() {
const isPGXL = pgxl.type === 'pgxl'; // The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
const isACOM = (pgxl.type || '').startsWith('acom'); // presents it as brand + model.
const isSerial = pgxl.transport === 'serial';
// The stored `type` stays the single flat value ("spe13", "acom700", "pgxl" —
// binding compatibility); the UI presents it as brand + model.
const brand = isPGXL ? 'pgxl' : isACOM ? 'acom' : 'spe';
const brandModels: Record<string, { value: string; label: string }[]> = { const brandModels: Record<string, { value: string; label: string }[]> = {
spe: [ spe: [
{ value: 'spe13', label: 'Expert 1.3K-FA' }, { value: 'spe13', label: 'Expert 1.3K-FA' },
@@ -2729,30 +2713,51 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{ value: 'acom2020', label: '2020S' }, { value: 'acom2020', label: '2020S' },
], ],
}; };
const brandOf = (ty: string) => (!ty || ty === 'pgxl') ? 'pgxl' : ty.startsWith('acom') ? 'acom' : 'spe';
const patchAmp = (i: number, patch: Partial<AmpUI>) => setAmps((l) => l.map((a, j) => (j === i ? { ...a, ...patch } : a)));
// Each family has a fixed serial speed: SPE talks 115200, the ACOM S-series is // Each family has a fixed serial speed: SPE talks 115200, the ACOM S-series is
// 9600 8N1 — preset it so switching brand just works. PGXL is TCP-only. // 9600 8N1 — preset it so switching brand just works. PGXL is TCP-only.
const applyType = (v: string) => setPgxl((s) => ({ const applyType = (i: number, v: string) => patchAmp(i, {
...s, type: v, type: v,
transport: v === 'pgxl' ? 'tcp' : s.transport, transport: v === 'pgxl' ? 'tcp' : amps[i].transport,
baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : s.baud, baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : amps[i].baud,
})); });
const addAmp = () => setAmps((l) => [...l, {
id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
}]);
return ( return (
<> <>
<SectionHeader title="Amplifier" /> <SectionHeader title="Amplifier" hint={t('amp.hint')} />
<div className="space-y-4 max-w-xl"> <div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer"> {amps.length === 0 && (
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} /> <p className="text-sm text-muted-foreground">{t('amp.none')}</p>
Enable amplifier control )}
</label> {amps.map((amp, i) => {
const brand = brandOf(amp.type);
const isPGXL = brand === 'pgxl';
const isACOM = brand === 'acom';
const isSerial = !isPGXL && amp.transport === 'serial';
return (
<div key={amp.id || `new-${i}`} className="rounded-lg border border-border p-3 space-y-3">
<div className="flex items-center gap-2">
<Checkbox checked={amp.enabled} onCheckedChange={(c) => patchAmp(i, { enabled: !!c })} />
<Input className="h-8 flex-1" value={amp.name}
placeholder={t('amp.namePh')}
onChange={(e) => patchAmp(i, { name: e.target.value })} />
<Button variant="ghost" size="sm" className="h-8 text-danger" title={t('amp.remove')}
onClick={() => setAmps((l) => l.filter((_, j) => j !== i))}>
<Trash2 className="size-3.5" />
</Button>
</div>
{/* Connection gets the widest column — "Network (RS232-to-Ethernet)" was {/* Connection gets the widest column — "Network (RS232-to-Ethernet)"
truncated with 3 equal columns. */} was truncated with 3 equal columns. */}
<div className="grid grid-cols-[1fr_1fr_1.7fr] gap-3"> <div className="grid grid-cols-[1fr_1fr_1.7fr] gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label>Brand</Label> <Label>Brand</Label>
<Select value={brand} onValueChange={(b) => { <Select value={brand} onValueChange={(b) => {
if (b === brand) return; if (b === brand) return;
applyType(b === 'pgxl' ? 'pgxl' : brandModels[b][0].value); applyType(i, b === 'pgxl' ? 'pgxl' : brandModels[b][0].value);
}}> }}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger> <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
@@ -2773,7 +2778,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</SelectContent> </SelectContent>
</Select> </Select>
) : ( ) : (
<Select value={pgxl.type} onValueChange={applyType}> <Select value={amp.type} onValueChange={(v) => applyType(i, v)}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger> <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
{brandModels[brand].map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)} {brandModels[brand].map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)}
@@ -2781,12 +2786,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Select> </Select>
)} )}
</div> </div>
{/* PowerGenius is TCP-only; SPE and ACOM amps connect over USB serial or {/* PowerGenius is TCP-only; SPE and ACOM amps connect over USB serial
an RS232-to-Ethernet bridge, so they offer both. */} or an RS232-to-Ethernet bridge, so they offer both. */}
{!isPGXL && ( {!isPGXL && (
<div className="space-y-1"> <div className="space-y-1">
<Label>Connection</Label> <Label>Connection</Label>
<Select value={pgxl.transport} onValueChange={(v) => setPgxl((s) => ({ ...s, transport: v }))}> <Select value={amp.transport} onValueChange={(v) => patchAmp(i, { transport: v })}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger> <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="serial">USB (serial COM)</SelectItem> <SelectItem value="serial">USB (serial COM)</SelectItem>
@@ -2802,7 +2807,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="space-y-1 col-span-2"> <div className="space-y-1 col-span-2">
<Label>COM port</Label> <Label>COM port</Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select value={pgxl.com_port || '_'} onValueChange={(v) => setPgxl((s) => ({ ...s, com_port: v === '_' ? '' : v }))}> <Select value={amp.com_port || '_'} onValueChange={(v) => patchAmp(i, { com_port: v === '_' ? '' : v })}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger> <SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent> <SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>} {wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
@@ -2816,26 +2821,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Baud</Label> <Label>Baud</Label>
<Input type="number" min={1200} value={pgxl.baud} <Input type="number" min={1200} value={amp.baud}
onChange={(e) => setPgxl((s) => ({ ...s, baud: parseInt(e.target.value) || 115200 }))} className="font-mono" /> onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) || 115200 })} className="font-mono" />
</div> </div>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2"> <div className="space-y-1 col-span-2">
<Label>Host / IP</Label> <Label>Host / IP</Label>
<Input value={pgxl.host ?? ''} onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))} <Input value={amp.host ?? ''} onChange={(e) => patchAmp(i, { host: e.target.value })}
placeholder="192.168.1.70" className="font-mono" /> placeholder="192.168.1.70" className="font-mono" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>TCP port</Label> <Label>TCP port</Label>
<Input type="number" min={1} max={65535} value={pgxl.port} <Input type="number" min={1} max={65535} value={amp.port}
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))} className="font-mono" /> onChange={(e) => patchAmp(i, { port: parseInt(e.target.value) || 9008 })} className="font-mono" />
</div> </div>
</div> </div>
)} )}
{!isPGXL && pgxl.enabled && (isACOM ? <ACOMStatusCard /> : <SPEStatusCard />)} {amp.enabled && amp.id && <AmpStatusCard id={amp.id} />}
{!isPGXL && !isACOM && ( {!isPGXL && !isACOM && (
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT. SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.
@@ -2847,17 +2852,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</p> </p>
)} )}
</div> </div>
);
})}
<Button variant="outline" size="sm" onClick={addAmp}>
<Plus className="size-3.5 mr-1" /> {t('amp.add')}
</Button>
</div>
</> </>
); );
} }
function RotatorPanel() { function RotatorPanel() {
const isRG = (rotator as any).type === 'rotgenius'; const isRG = (rotator as any).type === 'rotgenius';
const isARCO = (rotator as any).type === 'arco';
return ( return (
<> <>
<SectionHeader <SectionHeader
title="Rotator" title="Rotator"
hint={isRG ? undefined : t('rot.hint')} hint={isRG || isARCO ? undefined : t('rot.hint')}
/> />
<div className="space-y-4 max-w-xl"> <div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -2867,14 +2879,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('rot.type')}</Label> <Label>{t('rot.type')}</Label>
{/* Switching to Rotator Genius moves the default port to its native {/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
9006; back to PstRotator restores 12000. */} (placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
<Select value={(rotator as any).type ?? 'pst'} <Select value={(rotator as any).type ?? 'pst'}
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : 12000 } as any))}> onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 } as any))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger> <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="pst">PstRotator (UDP)</SelectItem> <SelectItem value="pst">PstRotator (UDP)</SelectItem>
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem> <SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
<SelectItem value="arco">microHAM ARCO (LAN, GS-232A)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -2891,34 +2904,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Select> </Select>
</div> </div>
)} )}
{/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */}
{isARCO && (
<div className="space-y-1">
<Label>Connection</Label>
<Select value={(rotator as any).transport ?? 'tcp'}
onValueChange={(v) => setRotator((s) => ({ ...s, transport: v } as any))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="tcp">Network (LAN)</SelectItem>
<SelectItem value="serial">USB (serial COM)</SelectItem>
</SelectContent>
</Select>
</div> </div>
)}
</div>
{isARCO && (rotator as any).transport === 'serial' ? (
<div className="space-y-1 max-w-xs">
<Label>COM port</Label>
<div className="flex items-center gap-2">
<Select value={(rotator as any).com_port || '_'}
onValueChange={(v) => setRotator((s) => ({ ...s, com_port: v === '_' ? '' : v } as any))}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
<ArrowDown className="size-3.5 rotate-90" />
</Button>
</div>
</div>
) : (
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2"> <div className="space-y-1 col-span-2">
<Label>Host / IP</Label> <Label>Host / IP</Label>
<Input <Input
value={rotator.host ?? ''} value={rotator.host ?? ''}
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))} onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
placeholder={isRG ? '192.168.1.60' : '127.0.0.1'} placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'}
className="font-mono" className="font-mono"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>{isRG ? 'TCP port' : 'UDP port'}</Label> <Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
<Input <Input
type="number" min={1} max={65535} type="number" min={1} max={65535}
value={rotator.port} value={rotator.port}
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : 12000) }))} onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : isARCO ? 4001 : 12000) }))}
className="font-mono" className="font-mono"
/> />
</div> </div>
</div> </div>
{!isRG && ( )}
{!isRG && !isARCO && (
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} /> <Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
This rotator supports elevation (VHF / satellite) This rotator supports elevation (VHF / satellite)
</label> </label>
)} )}
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>} {isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
<div className="flex items-center gap-2 pt-2"> <div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}> <Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')} {rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
@@ -4583,6 +4630,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} /> <Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
{t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span> {t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
</label> </label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
{t('gen.groupDigital')} <span className="text-xs text-muted-foreground">{t('gen.groupDigitalHint')}</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} /> <Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span> {t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
+150 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react'; import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, Flame } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -13,6 +13,7 @@ import {
GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices, ListSerialPorts, TestStationDevice, ListDenkoviDevices, ListSerialPorts, TestStationDevice,
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, GetFlexState, FlexAmpOperate,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -246,6 +247,139 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
); );
} }
// AmplifierWidget brings the amplifier controls (Settings → Amplifier) into the
// Station Control tab, so an operator WITHOUT a FlexRadio/Icom panel still has
// them. Several amps can be configured (some ops run two SPEs in parallel) —
// the header dropdown picks which one this card shows; the choice is remembered.
function AmplifierWidget({ t }: { t: (k: string, v?: any) => string }) {
const [list, setList] = useState<any[]>([]);
const [sel, setSel] = useState<string>(() => localStorage.getItem('opslog.ampSel.station') || '');
const [flex, setFlex] = useState<any>(null);
useEffect(() => {
let alive = true;
// The Flex state rides along because a PGXL's OPERATE state comes from the
// radio (the direct GSCP status doesn't carry it).
const tick = () => Promise.all([
GetAmpStatuses().catch(() => []),
GetFlexState().catch(() => null),
]).then(([l, fx]: any[]) => { if (alive) { setList((l ?? []) as any[]); setFlex(fx); } });
tick();
const id = window.setInterval(tick, 1500);
return () => { alive = false; window.clearInterval(id); };
}, []);
const amp = list.find((a) => a.id === sel) ?? list[0];
if (!amp) return null;
const isACOM = !!amp.acom;
const isSPE = !!amp.spe;
const isPGXL = !isACOM && !isSPE;
const viaFlex = isPGXL && !!flex?.amp_available;
const raw = amp.spe ?? amp.acom ?? amp.pgxl ?? { connected: false };
const st: any = isPGXL
? { ...raw, connected: !!raw.connected || viaFlex, operate: viaFlex ? !!flex.amp_operate : !!raw.operate }
: raw;
const doOperate = () => {
const want = !st.operate;
(isPGXL && viaFlex ? FlexAmpOperate(want) : AmpOperate(amp.id, want)).catch(() => {});
};
const maxW = isACOM ? (Number(st.max_w) || 800) : ({ '13K': 1300, '15K': 1500, '2K': 2000 } as Record<string, number>)[st.model] || 1500;
const outW = Number(isACOM ? st.fwd_w : st.output_w) || 0;
const frac = Math.min(1, outW / maxW);
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Flame className="size-4 text-primary" />
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{t('flxp.amplifier')}</div>
{list.length <= 1 && <div className="text-[10px] text-muted-foreground font-mono truncate">{amp.name}</div>}
</div>
{list.length > 1 && (
<select value={amp.id} title={t('flxp.ampPick')}
onChange={(e) => { setSel(e.target.value); localStorage.setItem('opslog.ampSel.station', e.target.value); }}
className="h-7 rounded-md border border-border bg-card px-1.5 text-xs font-semibold outline-none min-w-0">
{list.map((a: any) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
)}
<span className={cn('ml-auto size-2 rounded-full shrink-0', st.connected ? 'bg-success' : 'bg-muted-foreground/40')}
title={st.connected ? t('station.online') : (st.last_error || t('station.offline'))} />
</div>
<div className="p-3 space-y-2">
{isPGXL ? (
<div className="flex items-center gap-2 flex-wrap">
<button type="button" disabled={!st.connected}
onClick={doOperate}
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{st.operate ? 'OPERATE' : 'STANDBY'}
</button>
{(['STANDARD', 'CONTEST', 'BROADCAST'] as const).map((m) => (
<button key={m} type="button" disabled={!st.connected}
onClick={() => AmpFanMode(amp.id, m).catch(() => {})}
className={cn('px-2.5 py-1.5 rounded-md border text-xs font-semibold disabled:opacity-40',
st.fan_mode === m ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted/30 border-border hover:bg-muted')}>
{m === 'STANDARD' ? t('flxp.fanStandard') : m === 'CONTEST' ? t('flxp.fanContest') : t('flxp.fanBroadcast')}
</button>
))}
<span className="text-xs text-muted-foreground font-mono">{st.state || ''}{st.temperature ? ` · ${Math.round(st.temperature)}°C` : ''}</span>
</div>
) : (
<>
<div className="flex items-center gap-2 flex-wrap">
<button type="button" disabled={!st.connected}
onClick={doOperate}
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{st.operate ? 'OPERATE' : 'STANDBY'}
</button>
<div className="inline-flex rounded-lg overflow-hidden border border-success/70">
<button type="button"
disabled={isACOM ? !(st.port_open && st.transport === 'serial') : !st.connected}
onClick={() => AmpPower(amp.id, true).catch(() => {})}
className="px-2.5 py-1.5 text-xs font-bold bg-card text-success hover:bg-success/15 disabled:opacity-40">ON</button>
<button type="button" disabled={!st.connected}
onClick={() => AmpPower(amp.id, false).catch(() => {})}
className="px-2.5 py-1.5 text-xs font-bold bg-card text-danger border-l border-success/70 hover:bg-danger/15 disabled:opacity-40">OFF</button>
</div>
{!isACOM && (
<div className="inline-flex rounded-lg overflow-hidden border border-border">
{(['L', 'M', 'H'] as const).map((lvl, i) => (
<button key={lvl} type="button" disabled={!st.connected}
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
className={cn('px-2.5 py-1.5 text-xs font-bold disabled:opacity-40', i > 0 && 'border-l border-border',
(st.power_level || '').trim().toUpperCase() === lvl ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{lvl}
</button>
))}
</div>
)}
</div>
<div className="text-xs font-mono text-muted-foreground tabular-nums">
{st.connected
? <>
{isACOM ? (st.state || '') : (st.tx ? 'TX' : 'RX')}
{st.band ? ` · ${st.band}` : ''} · {outW}W · SWR {Number((isACOM ? st.swr : st.swr_ant) ?? 0).toFixed(1)} · {st.temp_c}°C
{isACOM && st.fan ? ` · Fan ${st.fan}` : ''}
</>
: (isACOM ? t('flxp.acomOffline') : t('flxp.speOffline'))}
</div>
{st.connected && (
<div>
<div className="h-2 rounded bg-muted overflow-hidden">
<div className={cn('h-full transition-all', frac > 0.9 ? 'bg-danger' : frac > 0.75 ? 'bg-warning' : 'bg-primary')}
style={{ width: `${Math.round(frac * 100)}%` }} />
</div>
<div className="text-[10px] text-muted-foreground mt-0.5">{t('flxp.outputPower')} {outW} W / {maxW} W</div>
</div>
)}
{(st.err_text || st.warnings || st.alarms) && (
<div className="text-[11px] font-bold text-danger"> {st.err_text || `${st.warnings || ''} ${st.alarms || ''}`}</div>
)}
</>
)}
</div>
</div>
);
}
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) { export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
const { t } = useI18n(); const { t } = useI18n();
const [devices, setDevices] = useState<Device[]>([]); const [devices, setDevices] = useState<Device[]>([]);
@@ -260,6 +394,17 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
}); });
const dragId = useRef<string | null>(null); const dragId = useRef<string | null>(null);
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto'); const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
// Amplifiers (Settings → Amplifier): shown here so operators without a
// FlexRadio panel still get the controls. Re-read every 5s so enabling an
// amp in Settings makes the widget appear without reopening the tab.
const [ampCount, setAmpCount] = useState(0);
useEffect(() => {
let alive = true;
const load = () => GetAmpStatuses().then((l: any) => { if (alive) setAmpCount((l ?? []).length); }).catch(() => {});
load();
const id = window.setInterval(load, 5000);
return () => { alive = false; window.clearInterval(id); };
}, []);
const loadDevices = useCallback(async () => { const loadDevices = useCallback(async () => {
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ } try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
@@ -380,13 +525,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
if (ant.enabled) { if (ant.enabled) {
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> }); widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
} }
if (ampCount > 0) {
widgets.push({ id: 'amplifier', node: <AmplifierWidget t={t} /> });
}
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) }); for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; }; const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i)); const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
const widgetIds = ordered.map((w) => w.id); const widgetIds = ordered.map((w) => w.id);
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled; const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && ampCount === 0;
// Column count controls the layout: with 4 widgets, choosing "2" lays them out // Column count controls the layout: with 4 widgets, choosing "2" lays them out
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to // 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
File diff suppressed because one or more lines are too long
+1
View File
@@ -25,6 +25,7 @@ const PORTABLE_KEYS = [
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom) 'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom) 'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing 'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane) 'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
'opslog.mapBasemap', // world map basemap (light / street / satellite) 'opslog.mapBasemap', // world map basemap (light / street / satellite)
// Cluster filter selections — restored on reopen. // Cluster filter selections — restored on reopen.
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.20.7'; export const APP_VERSION = '0.20.9';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+40
View File
@@ -36,6 +36,14 @@ export function ActivateProfile(arg1:number):Promise<void>;
export function AddQSO(arg1:qso.QSO):Promise<number>; export function AddQSO(arg1:qso.QSO):Promise<number>;
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
export function AmpOperate(arg1:string,arg2:boolean):Promise<void>;
export function AmpPower(arg1:string,arg2:boolean):Promise<void>;
export function AmpPowerLevel(arg1:string,arg2:string):Promise<void>;
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>; export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
export function AntGeniusDeselect(arg1:number):Promise<void>; export function AntGeniusDeselect(arg1:number):Promise<void>;
@@ -210,6 +218,8 @@ export function FlexSetANF(arg1:boolean):Promise<void>;
export function FlexSetANFLevel(arg1:number):Promise<void>; export function FlexSetANFLevel(arg1:number):Promise<void>;
export function FlexSetANFT(arg1:boolean):Promise<void>;
export function FlexSetAPF(arg1:boolean):Promise<void>; export function FlexSetAPF(arg1:boolean):Promise<void>;
export function FlexSetAPFLevel(arg1:number):Promise<void>; export function FlexSetAPFLevel(arg1:number):Promise<void>;
@@ -230,10 +240,20 @@ export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
export function FlexSetCWSpeed(arg1:number):Promise<void>; export function FlexSetCWSpeed(arg1:number):Promise<void>;
export function FlexSetDAX(arg1:number):Promise<void>;
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>; export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
export function FlexSetKeySpeed(arg1:number):Promise<void>; export function FlexSetKeySpeed(arg1:number):Promise<void>;
export function FlexSetLMSANF(arg1:boolean):Promise<void>;
export function FlexSetLMSANFLevel(arg1:number):Promise<void>;
export function FlexSetLMSNR(arg1:boolean):Promise<void>;
export function FlexSetLMSNRLevel(arg1:number):Promise<void>;
export function FlexSetMic(arg1:number):Promise<void>; export function FlexSetMic(arg1:number):Promise<void>;
export function FlexSetMicProfile(arg1:string):Promise<void>; export function FlexSetMicProfile(arg1:string):Promise<void>;
@@ -250,6 +270,10 @@ export function FlexSetNBLevel(arg1:number):Promise<void>;
export function FlexSetNR(arg1:boolean):Promise<void>; export function FlexSetNR(arg1:boolean):Promise<void>;
export function FlexSetNRF(arg1:boolean):Promise<void>;
export function FlexSetNRFLevel(arg1:number):Promise<void>;
export function FlexSetNRLevel(arg1:number):Promise<void>; export function FlexSetNRLevel(arg1:number):Promise<void>;
export function FlexSetPower(arg1:number):Promise<void>; export function FlexSetPower(arg1:number):Promise<void>;
@@ -262,14 +286,22 @@ export function FlexSetRIT(arg1:boolean):Promise<void>;
export function FlexSetRITFreq(arg1:number):Promise<void>; export function FlexSetRITFreq(arg1:number):Promise<void>;
export function FlexSetRNN(arg1:boolean):Promise<void>;
export function FlexSetRXAntenna(arg1:string):Promise<void>; export function FlexSetRXAntenna(arg1:string):Promise<void>;
export function FlexSetSidetoneLevel(arg1:number):Promise<void>; export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
export function FlexSetSpeexNR(arg1:boolean):Promise<void>;
export function FlexSetSpeexNRLevel(arg1:number):Promise<void>;
export function FlexSetSplit(arg1:boolean):Promise<void>; export function FlexSetSplit(arg1:boolean):Promise<void>;
export function FlexSetTXAntenna(arg1:string):Promise<void>; export function FlexSetTXAntenna(arg1:string):Promise<void>;
export function FlexSetTXDAX(arg1:boolean):Promise<void>;
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>; export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
export function FlexSetTXSlice(arg1:number):Promise<void>; export function FlexSetTXSlice(arg1:number):Promise<void>;
@@ -302,6 +334,10 @@ export function GetActiveProfile():Promise<profile.Profile>;
export function GetAlertEmailTo():Promise<string>; export function GetAlertEmailTo():Promise<string>;
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>; export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
export function GetAntGeniusStatus():Promise<antgenius.Status>; export function GetAntGeniusStatus():Promise<antgenius.Status>;
@@ -630,6 +666,8 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
export function PGXLSetFanMode(arg1:string):Promise<void>; export function PGXLSetFanMode(arg1:string):Promise<void>;
export function PGXLSetOperate(arg1:boolean):Promise<void>;
export function PickADIFMonitorFile():Promise<string>; export function PickADIFMonitorFile():Promise<string>;
export function PickAudioFolder():Promise<string>; export function PickAudioFolder():Promise<string>;
@@ -736,6 +774,8 @@ export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>; export function SaveAlertRule(arg1:alerts.Rule):Promise<alerts.Rule>;
export function SaveAmplifiers(arg1:Array<main.AmpConfig>):Promise<void>;
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>; export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>; export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
+80
View File
@@ -26,6 +26,22 @@ export function AddQSO(arg1) {
return window['go']['main']['App']['AddQSO'](arg1); return window['go']['main']['App']['AddQSO'](arg1);
} }
export function AmpFanMode(arg1, arg2) {
return window['go']['main']['App']['AmpFanMode'](arg1, arg2);
}
export function AmpOperate(arg1, arg2) {
return window['go']['main']['App']['AmpOperate'](arg1, arg2);
}
export function AmpPower(arg1, arg2) {
return window['go']['main']['App']['AmpPower'](arg1, arg2);
}
export function AmpPowerLevel(arg1, arg2) {
return window['go']['main']['App']['AmpPowerLevel'](arg1, arg2);
}
export function AntGeniusActivate(arg1, arg2) { export function AntGeniusActivate(arg1, arg2) {
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2); return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
} }
@@ -374,6 +390,10 @@ export function FlexSetANFLevel(arg1) {
return window['go']['main']['App']['FlexSetANFLevel'](arg1); return window['go']['main']['App']['FlexSetANFLevel'](arg1);
} }
export function FlexSetANFT(arg1) {
return window['go']['main']['App']['FlexSetANFT'](arg1);
}
export function FlexSetAPF(arg1) { export function FlexSetAPF(arg1) {
return window['go']['main']['App']['FlexSetAPF'](arg1); return window['go']['main']['App']['FlexSetAPF'](arg1);
} }
@@ -414,6 +434,10 @@ export function FlexSetCWSpeed(arg1) {
return window['go']['main']['App']['FlexSetCWSpeed'](arg1); return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
} }
export function FlexSetDAX(arg1) {
return window['go']['main']['App']['FlexSetDAX'](arg1);
}
export function FlexSetFilter(arg1, arg2) { export function FlexSetFilter(arg1, arg2) {
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2); return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
} }
@@ -422,6 +446,22 @@ export function FlexSetKeySpeed(arg1) {
return window['go']['main']['App']['FlexSetKeySpeed'](arg1); return window['go']['main']['App']['FlexSetKeySpeed'](arg1);
} }
export function FlexSetLMSANF(arg1) {
return window['go']['main']['App']['FlexSetLMSANF'](arg1);
}
export function FlexSetLMSANFLevel(arg1) {
return window['go']['main']['App']['FlexSetLMSANFLevel'](arg1);
}
export function FlexSetLMSNR(arg1) {
return window['go']['main']['App']['FlexSetLMSNR'](arg1);
}
export function FlexSetLMSNRLevel(arg1) {
return window['go']['main']['App']['FlexSetLMSNRLevel'](arg1);
}
export function FlexSetMic(arg1) { export function FlexSetMic(arg1) {
return window['go']['main']['App']['FlexSetMic'](arg1); return window['go']['main']['App']['FlexSetMic'](arg1);
} }
@@ -454,6 +494,14 @@ export function FlexSetNR(arg1) {
return window['go']['main']['App']['FlexSetNR'](arg1); return window['go']['main']['App']['FlexSetNR'](arg1);
} }
export function FlexSetNRF(arg1) {
return window['go']['main']['App']['FlexSetNRF'](arg1);
}
export function FlexSetNRFLevel(arg1) {
return window['go']['main']['App']['FlexSetNRFLevel'](arg1);
}
export function FlexSetNRLevel(arg1) { export function FlexSetNRLevel(arg1) {
return window['go']['main']['App']['FlexSetNRLevel'](arg1); return window['go']['main']['App']['FlexSetNRLevel'](arg1);
} }
@@ -478,6 +526,10 @@ export function FlexSetRITFreq(arg1) {
return window['go']['main']['App']['FlexSetRITFreq'](arg1); return window['go']['main']['App']['FlexSetRITFreq'](arg1);
} }
export function FlexSetRNN(arg1) {
return window['go']['main']['App']['FlexSetRNN'](arg1);
}
export function FlexSetRXAntenna(arg1) { export function FlexSetRXAntenna(arg1) {
return window['go']['main']['App']['FlexSetRXAntenna'](arg1); return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
} }
@@ -486,6 +538,14 @@ export function FlexSetSidetoneLevel(arg1) {
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1); return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
} }
export function FlexSetSpeexNR(arg1) {
return window['go']['main']['App']['FlexSetSpeexNR'](arg1);
}
export function FlexSetSpeexNRLevel(arg1) {
return window['go']['main']['App']['FlexSetSpeexNRLevel'](arg1);
}
export function FlexSetSplit(arg1) { export function FlexSetSplit(arg1) {
return window['go']['main']['App']['FlexSetSplit'](arg1); return window['go']['main']['App']['FlexSetSplit'](arg1);
} }
@@ -494,6 +554,10 @@ export function FlexSetTXAntenna(arg1) {
return window['go']['main']['App']['FlexSetTXAntenna'](arg1); return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
} }
export function FlexSetTXDAX(arg1) {
return window['go']['main']['App']['FlexSetTXDAX'](arg1);
}
export function FlexSetTXFilter(arg1, arg2) { export function FlexSetTXFilter(arg1, arg2) {
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2); return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
} }
@@ -558,6 +622,14 @@ export function GetAlertEmailTo() {
return window['go']['main']['App']['GetAlertEmailTo'](); return window['go']['main']['App']['GetAlertEmailTo']();
} }
export function GetAmpStatuses() {
return window['go']['main']['App']['GetAmpStatuses']();
}
export function GetAmplifiers() {
return window['go']['main']['App']['GetAmplifiers']();
}
export function GetAntGeniusSettings() { export function GetAntGeniusSettings() {
return window['go']['main']['App']['GetAntGeniusSettings'](); return window['go']['main']['App']['GetAntGeniusSettings']();
} }
@@ -1214,6 +1286,10 @@ export function PGXLSetFanMode(arg1) {
return window['go']['main']['App']['PGXLSetFanMode'](arg1); return window['go']['main']['App']['PGXLSetFanMode'](arg1);
} }
export function PGXLSetOperate(arg1) {
return window['go']['main']['App']['PGXLSetOperate'](arg1);
}
export function PickADIFMonitorFile() { export function PickADIFMonitorFile() {
return window['go']['main']['App']['PickADIFMonitorFile'](); return window['go']['main']['App']['PickADIFMonitorFile']();
} }
@@ -1426,6 +1502,10 @@ export function SaveAlertRule(arg1) {
return window['go']['main']['App']['SaveAlertRule'](arg1); return window['go']['main']['App']['SaveAlertRule'](arg1);
} }
export function SaveAmplifiers(arg1) {
return window['go']['main']['App']['SaveAmplifiers'](arg1);
}
export function SaveAntGeniusSettings(arg1) { export function SaveAntGeniusSettings(arg1) {
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1); return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
} }
+100
View File
@@ -777,6 +777,19 @@ export namespace cat {
anf_level: number; anf_level: number;
wnb: boolean; wnb: boolean;
wnb_level: number; wnb_level: number;
lms_nr: boolean;
lms_nr_level: number;
lms_anf: boolean;
lms_anf_level: number;
speex_nr: boolean;
speex_nr_level: number;
rnn: boolean;
anft: boolean;
nrf: boolean;
nrf_level: number;
dsp_v4: boolean;
dax_ch: number;
tx_dax: boolean;
rit: boolean; rit: boolean;
rit_freq: number; rit_freq: number;
xit: boolean; xit: boolean;
@@ -844,6 +857,19 @@ export namespace cat {
this.anf_level = source["anf_level"]; this.anf_level = source["anf_level"];
this.wnb = source["wnb"]; this.wnb = source["wnb"];
this.wnb_level = source["wnb_level"]; this.wnb_level = source["wnb_level"];
this.lms_nr = source["lms_nr"];
this.lms_nr_level = source["lms_nr_level"];
this.lms_anf = source["lms_anf"];
this.lms_anf_level = source["lms_anf_level"];
this.speex_nr = source["speex_nr"];
this.speex_nr_level = source["speex_nr_level"];
this.rnn = source["rnn"];
this.anft = source["anft"];
this.nrf = source["nrf"];
this.nrf_level = source["nrf_level"];
this.dsp_v4 = source["dsp_v4"];
this.dax_ch = source["dax_ch"];
this.tx_dax = source["tx_dax"];
this.rit = source["rit"]; this.rit = source["rit"];
this.rit_freq = source["rit_freq"]; this.rit_freq = source["rit_freq"];
this.xit = source["xit"]; this.xit = source["xit"];
@@ -1400,6 +1426,74 @@ export namespace main {
} }
} }
export class AmpConfig {
id: string;
name: string;
enabled: boolean;
type: string;
transport: string;
host: string;
port: number;
com_port: string;
baud: number;
static createFrom(source: any = {}) {
return new AmpConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.enabled = source["enabled"];
this.type = source["type"];
this.transport = source["transport"];
this.host = source["host"];
this.port = source["port"];
this.com_port = source["com_port"];
this.baud = source["baud"];
}
}
export class AmpStatus {
id: string;
name: string;
type: string;
pgxl?: powergenius.Status;
spe?: spe.Status;
acom?: acom.Status;
static createFrom(source: any = {}) {
return new AmpStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.type = source["type"];
this.pgxl = this.convertValues(source["pgxl"], powergenius.Status);
this.spe = this.convertValues(source["spe"], spe.Status);
this.acom = this.convertValues(source["acom"], acom.Status);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class AntGeniusSettings { export class AntGeniusSettings {
enabled: boolean; enabled: boolean;
host: string; host: string;
@@ -2583,6 +2677,8 @@ export namespace main {
port: number; port: number;
has_elevation: boolean; has_elevation: boolean;
rotator_num: number; rotator_num: number;
transport: string;
com_port: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new RotatorSettings(source); return new RotatorSettings(source);
@@ -2596,6 +2692,8 @@ export namespace main {
this.port = source["port"]; this.port = source["port"];
this.has_elevation = source["has_elevation"]; this.has_elevation = source["has_elevation"];
this.rotator_num = source["rotator_num"]; this.rotator_num = source["rotator_num"];
this.transport = source["transport"];
this.com_port = source["com_port"];
} }
} }
export class SecretStatus { export class SecretStatus {
@@ -3208,6 +3306,7 @@ export namespace powergenius {
state?: string; state?: string;
fan_mode?: string; fan_mode?: string;
temperature: number; temperature: number;
operate: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Status(source); return new Status(source);
@@ -3221,6 +3320,7 @@ export namespace powergenius {
this.state = source["state"]; this.state = source["state"];
this.fan_mode = source["fan_mode"]; this.fan_mode = source["fan_mode"];
this.temperature = source["temperature"]; this.temperature = source["temperature"];
this.operate = source["operate"];
} }
} }
+29
View File
@@ -304,6 +304,22 @@ type FlexTXState struct {
ANFLevel int `json:"anf_level"` ANFLevel int `json:"anf_level"`
WNB bool `json:"wnb"` WNB bool `json:"wnb"`
WNBLevel int `json:"wnb_level"` WNBLevel int `json:"wnb_level"`
// SmartSDR v4 DSP (8000/Aurora series): NRL/ANFL (legacy LMS), NRS
// (spectral subtraction), RNN (AI NR), ANFT (FFT notch), NRF (NR w/filter).
// DSPV4 is true once the radio reported any of these keys — gates the UI.
LMSNR bool `json:"lms_nr"`
LMSNRLevel int `json:"lms_nr_level"`
LMSANF bool `json:"lms_anf"`
LMSANFLevel int `json:"lms_anf_level"`
SpeexNR bool `json:"speex_nr"`
SpeexNRLevel int `json:"speex_nr_level"`
RNN bool `json:"rnn"`
ANFT bool `json:"anft"`
NRF bool `json:"nrf"`
NRFLevel int `json:"nrf_level"`
DSPV4 bool `json:"dsp_v4"`
DAXCh int `json:"dax_ch"` // DAX audio channel of the active slice (0 = off, 1-8)
TXDAX bool `json:"tx_dax"` // DAX is the TX audio source (SmartSDR transmit-bar DAX button)
// RIT/XIT — offsets applied to the active slice's RX / TX frequency without // RIT/XIT — offsets applied to the active slice's RX / TX frequency without
// moving the slice. The offset survives the switch being turned off, so // moving the slice. The offset survives the switch being turned off, so
// re-enabling restores it, exactly like the radio's own knob. // re-enabling restores it, exactly like the radio's own knob.
@@ -386,6 +402,19 @@ type FlexController interface {
SetAPFLevel(int) error SetAPFLevel(int) error
SetWNB(bool) error SetWNB(bool) error
SetWNBLevel(int) error SetWNBLevel(int) error
// SmartSDR v4 DSP (8000/Aurora): NRL / ANFL / NRS / RNN / ANFT / NRF.
SetLMSNR(bool) error
SetLMSNRLevel(int) error
SetLMSANF(bool) error
SetLMSANFLevel(int) error
SetSpeexNR(bool) error
SetSpeexNRLevel(int) error
SetRNN(bool) error
SetANFT(bool) error
SetNRF(bool) error
SetNRFLevel(int) error
SetDAX(int) error // slice RX DAX channel 0 (off) - 8
SetTXDAX(bool) error // TX audio from DAX (transmit set dax=)
SetRIT(bool) error SetRIT(bool) error
SetRITFreq(int) error SetRITFreq(int) error
SetXIT(bool) error SetXIT(bool) error
+108
View File
@@ -90,6 +90,22 @@ type flexSlice struct {
apfLevel int apfLevel int
wnb bool // wideband noise blanker wnb bool // wideband noise blanker
wnbLevel int wnbLevel int
// SmartSDR v4 DSP (8000/Aurora series). Key names per the FlexLib slice
// docs: lms_nr(NRL) / lms_anf(ANFL) / speex_nr(NRS) / rnnoise(RNN) /
// anft(ANFT) / nrf(NRF). Older radios never report them — dspV4 flips true
// the first time any of these keys appears, and gates the extra UI rows.
lmsNR bool // NRL — legacy LMS noise reduction
lmsNRLevel int
lmsANF bool // ANFL — legacy LMS auto-notch
lmsANFLevel int
speexNR bool // NRS — spectral subtraction NR
speexNRLevel int
rnnoise bool // RNN — AI noise reduction (on/off only)
anft bool // ANFT — FFT-based auto-notch (on/off only)
nrf bool // NRF — noise reduction w/ filter
nrfLevel int
dspV4 bool
daxCh int // DAX audio channel for this slice (0 = off, 1-8)
rit bool // receive incremental tuning enabled rit bool // receive incremental tuning enabled
ritFreq int // RIT offset in Hz (negative = down) ritFreq int // RIT offset in Hz (negative = down)
xit bool // transmit incremental tuning enabled xit bool // transmit incremental tuning enabled
@@ -120,6 +136,7 @@ type flexTX struct {
micLevel int micLevel int
filterLow int // TX filter low cut (Hz) filterLow int // TX filter low cut (Hz)
filterHigh int // TX filter high cut (Hz) filterHigh int // TX filter high cut (Hz)
dax bool // TX audio comes from DAX (SmartSDR's transmit-bar DAX button)
atuStatus string atuStatus string
atuMemories bool atuMemories bool
// CW keyer params (set via the top-level "cw" commands). // CW keyer params (set via the top-level "cw" commands).
@@ -431,6 +448,8 @@ func (f *Flex) handleStatus(payload string) {
f.tx.tunePower = atoiDefault(val, f.tx.tunePower) f.tx.tunePower = atoiDefault(val, f.tx.tunePower)
case "tune": case "tune":
f.tx.tune = val == "1" f.tx.tune = val == "1"
case "dax":
f.tx.dax = val == "1"
case "vox_enable": case "vox_enable":
f.tx.voxEnable = val == "1" f.tx.voxEnable = val == "1"
case "vox_level": case "vox_level":
@@ -809,6 +828,28 @@ func (f *Flex) handleStatus(payload string) {
s.wnb = val == "1" s.wnb = val == "1"
case "wnb_level": case "wnb_level":
s.wnbLevel = atoiDefault(val, s.wnbLevel) s.wnbLevel = atoiDefault(val, s.wnbLevel)
case "lms_nr":
s.lmsNR, s.dspV4 = val == "1", true
case "lms_nr_level":
s.lmsNRLevel, s.dspV4 = atoiDefault(val, s.lmsNRLevel), true
case "lms_anf":
s.lmsANF, s.dspV4 = val == "1", true
case "lms_anf_level":
s.lmsANFLevel, s.dspV4 = atoiDefault(val, s.lmsANFLevel), true
case "speex_nr":
s.speexNR, s.dspV4 = val == "1", true
case "speex_nr_level":
s.speexNRLevel, s.dspV4 = atoiDefault(val, s.speexNRLevel), true
case "rnnoise":
s.rnnoise, s.dspV4 = val == "1", true
case "anft":
s.anft, s.dspV4 = val == "1", true
case "nrf":
s.nrf, s.dspV4 = val == "1", true
case "nrf_level":
s.nrfLevel, s.dspV4 = atoiDefault(val, s.nrfLevel), true
case "dax":
s.daxCh = atoiDefault(val, s.daxCh)
case "rit_on": case "rit_on":
s.rit = val == "1" s.rit = val == "1"
case "rit_freq": case "rit_freq":
@@ -1274,6 +1315,7 @@ func (f *Flex) FlexState() FlexTXState {
MicLevel: f.tx.micLevel, MicLevel: f.tx.micLevel,
TXFilterLow: f.tx.filterLow, TXFilterLow: f.tx.filterLow,
TXFilterHigh: f.tx.filterHigh, TXFilterHigh: f.tx.filterHigh,
TXDAX: f.tx.dax,
MicProfile: f.micProfile, MicProfile: f.micProfile,
MicProfiles: f.micProfiles, MicProfiles: f.micProfiles,
ATUStatus: f.tx.atuStatus, ATUStatus: f.tx.atuStatus,
@@ -1328,6 +1370,18 @@ func (f *Flex) FlexState() FlexTXState {
st.APFLevel = rx.apfLevel st.APFLevel = rx.apfLevel
st.WNB = rx.wnb st.WNB = rx.wnb
st.WNBLevel = rx.wnbLevel st.WNBLevel = rx.wnbLevel
st.LMSNR = rx.lmsNR
st.LMSNRLevel = rx.lmsNRLevel
st.LMSANF = rx.lmsANF
st.LMSANFLevel = rx.lmsANFLevel
st.SpeexNR = rx.speexNR
st.SpeexNRLevel = rx.speexNRLevel
st.RNN = rx.rnnoise
st.ANFT = rx.anft
st.NRF = rx.nrf
st.NRFLevel = rx.nrfLevel
st.DSPV4 = rx.dspV4
st.DAXCh = rx.daxCh
st.RIT = rx.rit st.RIT = rx.rit
st.RITFreq = rx.ritFreq st.RITFreq = rx.ritFreq
st.XIT = rx.xit st.XIT = rx.xit
@@ -1396,6 +1450,28 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.apf = val == "1" rx.apf = val == "1"
case "apf_level": case "apf_level":
rx.apfLevel = toInt(val) rx.apfLevel = toInt(val)
case "lms_nr":
rx.lmsNR = val == "1"
case "lms_nr_level":
rx.lmsNRLevel = toInt(val)
case "lms_anf":
rx.lmsANF = val == "1"
case "lms_anf_level":
rx.lmsANFLevel = toInt(val)
case "speex_nr":
rx.speexNR = val == "1"
case "speex_nr_level":
rx.speexNRLevel = toInt(val)
case "rnnoise":
rx.rnnoise = val == "1"
case "anft":
rx.anft = val == "1"
case "nrf":
rx.nrf = val == "1"
case "nrf_level":
rx.nrfLevel = toInt(val)
case "dax":
rx.daxCh = toInt(val)
case "rxant": case "rxant":
rx.rxAnt = fmt.Sprint(val) rx.rxAnt = fmt.Sprint(val)
case "txant": case "txant":
@@ -1515,6 +1591,7 @@ func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on))
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) } func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) } func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) } func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active // RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
// slice, without moving the slice itself. SmartSDR keeps the offset even while the // slice, without moving the slice itself. SmartSDR keeps the offset even while the
// switch is off, so turning RIT back on restores the last offset — same as the // switch is off, so turning RIT back on restores the last offset — same as the
@@ -1537,6 +1614,37 @@ func clampOffset(hz int) int {
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) } func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) } func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
// SmartSDR v4 DSP (8000/Aurora series) — the extra noise tools SmartSDR ships
// beyond nb/nr/anf/wnb. Key names per the FlexLib slice command docs.
func (f *Flex) SetLMSNR(on bool) error { return f.sendSlice("lms_nr", boolFlex(on)) }
func (f *Flex) SetLMSNRLevel(l int) error { return f.sendSlice("lms_nr_level", clampLevel(l)) }
func (f *Flex) SetLMSANF(on bool) error { return f.sendSlice("lms_anf", boolFlex(on)) }
func (f *Flex) SetLMSANFLevel(l int) error { return f.sendSlice("lms_anf_level", clampLevel(l)) }
func (f *Flex) SetSpeexNR(on bool) error { return f.sendSlice("speex_nr", boolFlex(on)) }
func (f *Flex) SetSpeexNRLevel(l int) error { return f.sendSlice("speex_nr_level", clampLevel(l)) }
func (f *Flex) SetRNN(on bool) error { return f.sendSlice("rnnoise", boolFlex(on)) }
func (f *Flex) SetANFT(on bool) error { return f.sendSlice("anft", boolFlex(on)) }
func (f *Flex) SetNRF(on bool) error { return f.sendSlice("nrf", boolFlex(on)) }
func (f *Flex) SetNRFLevel(l int) error { return f.sendSlice("nrf_level", clampLevel(l)) }
// SetDAX routes the active slice's RX audio to a DAX channel (0 = off, 1-8) —
// the SmartSDR slice "DAX" selector, e.g. to feed WSJT-X via DAX audio.
func (f *Flex) SetDAX(ch int) error {
if ch < 0 {
ch = 0
}
if ch > 8 {
ch = 8
}
return f.sendSlice("dax", ch)
}
// SetTXDAX toggles DAX as the TRANSMIT audio source — SmartSDR's transmit-bar
// DAX button ("transmit set dax="), the one used to send WSJT-X audio.
func (f *Flex) SetTXDAX(on bool) error {
return f.txSet("transmit set dax="+boolFlex(on), "dax", func(t *flexTX) { t.dax = on })
}
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) } func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) } func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) }
+20 -1
View File
@@ -15,6 +15,8 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"hamlog/internal/applog"
) )
const ( const (
@@ -33,6 +35,7 @@ type Status struct {
State string `json:"state,omitempty"` // IDLE / TRANSMIT_A … State string `json:"state,omitempty"` // IDLE / TRANSMIT_A …
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
Temperature float64 `json:"temperature"` Temperature float64 `json:"temperature"`
Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)
} }
type Client struct { type Client struct {
@@ -45,6 +48,7 @@ type Client struct {
statusMu sync.RWMutex statusMu sync.RWMutex
status Status status Status
lastRaw string // last raw status payload — logged on change so unknown fields (operate?) can be mapped from a real amp
// Optimistic fan mode kept until the amp's status poll confirms it (or it // Optimistic fan mode kept until the amp's status poll confirms it (or it
// ages out) — otherwise a stale poll right after a change reverts the UI. // ages out) — otherwise a stale poll right after a change reverts the UI.
fanPending string fanPending string
@@ -119,8 +123,15 @@ func (c *Client) SetOperate(on bool) error {
if on { if on {
v = "1" v = "1"
} }
_, err := c.command("operate=" + v) if _, err := c.command("operate=" + v); err != nil {
return err return err
}
// Optimistic: the status poll's "operate" field (when the firmware reports
// one) confirms or corrects this.
c.statusMu.Lock()
c.status.Operate = on
c.statusMu.Unlock()
return nil
} }
func (c *Client) pollLoop() { func (c *Client) pollLoop() {
@@ -215,6 +226,12 @@ func (c *Client) parse(resp string) {
c.statusMu.Lock() c.statusMu.Lock()
c.status.Connected = true c.status.Connected = true
c.status.LastError = "" c.status.LastError = ""
// Log each DISTINCT status payload once: the PGXL's field set isn't fully
// documented, so this is how we learn the real key for e.g. operate/standby.
if data != c.lastRaw {
c.lastRaw = data
applog.Printf("pgxl: status raw=%q", data)
}
for _, pair := range strings.Fields(data) { for _, pair := range strings.Fields(data) {
kv := strings.SplitN(pair, "=", 2) kv := strings.SplitN(pair, "=", 2)
if len(kv) != 2 { if len(kv) != 2 {
@@ -223,6 +240,8 @@ func (c *Client) parse(resp string) {
switch kv[0] { switch kv[0] {
case "state": case "state":
c.status.State = kv[1] c.status.State = kv[1]
case "operate":
c.status.Operate = kv[1] == "1"
case "fanmode": case "fanmode":
dev := strings.ToUpper(kv[1]) dev := strings.ToUpper(kv[1])
// Honour a recent optimistic change until the amp confirms it. // Honour a recent optimistic change until the amp confirms it.
+19 -1
View File
@@ -1371,6 +1371,17 @@ func modeClass(mode string) string {
} }
} }
// GroupDigitalMode collapses every digital mode into the single "DIG" bucket
// (FT8, FT4, RTTY, PSK… all become one mode) while leaving phone modes and CW
// untouched. Used as the optional slot normaliser when the operator prefers
// DXCC-style mode classes over per-mode slots (Settings → General).
func GroupDigitalMode(mode string) string {
if modeClass(mode) == "DIG" {
return "DIG"
}
return strings.ToUpper(mode)
}
// BandMode is a (band, mode) pair used for the NEW SLOT check. // BandMode is a (band, mode) pair used for the NEW SLOT check.
type BandMode struct { type BandMode struct {
Band string `json:"band"` Band string `json:"band"`
@@ -1802,7 +1813,11 @@ type EntitySlot struct {
// 0 (unresolvable) skips the QSO. // 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, keyFor func(call string, storedDXCC int, country string) int) (map[int]*EntitySlot, error) { //
// normMode (nil = identity) maps each QSO's mode before it is stored as a
// Modes/Slots key — pass GroupDigitalMode to collapse all digital modes into
// one bucket. Callers must normalise their lookup mode the same way.
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string) (map[int]*EntitySlot, error) {
rows, err := r.db.QueryContext(ctx, rows, err := r.db.QueryContext(ctx,
`SELECT callsign, coalesce(dxcc,0), 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 != ''
@@ -1827,6 +1842,9 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store
if key == 0 { if key == 0 {
continue continue
} }
if normMode != nil {
mode = normMode(mode)
}
e, ok := out[key] e, ok := out[key]
if !ok { if !ok {
e = &EntitySlot{ e = &EntitySlot{
+143
View File
@@ -0,0 +1,143 @@
// Package gs232 drives rotator controllers that speak the Yaesu GS-232A
// protocol, over a raw TCP socket or a serial COM port. The target device is
// the microHAM ARCO: both its LAN "CONTROL PROTOCOL" setting (a TCP port) and
// its USB port ("USB CONTROL PROTOCOL", a virtual COM where the baud rate is
// irrelevant) can be set to speak Yaesu GS-232A — so OpsLog controls it
// directly, no PstRotator in between. ARCO accepts up to four parallel LAN
// connections, and commands are single CR-terminated lines, so short
// per-call connections (same idiom as the other rotator backends) work fine.
//
// GS-232A subset used:
//
// Maaa<CR> move to azimuth aaa (000-450)
// S<CR> stop rotation
// C<CR> query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
// flavour); both are parsed.
package gs232
import (
"fmt"
"io"
"net"
"regexp"
"strconv"
"strings"
"time"
"go.bug.st/serial"
)
const (
dialTimeout = 3 * time.Second
ioTimeout = 2 * time.Second
)
// Client is a stateless per-call sender, mirroring the pst/rotgenius idiom.
// Exactly one of (Host, Port) or ComPort is used, per Transport.
type Client struct {
Host string
Port int
ComPort string // serial transport: "COM5" etc. (ARCO USB virtual COM — any baud)
}
// New returns a TCP Client with sane defaults applied for empty fields. There
// is no standard port: the number is whatever the user typed into the ARCO's
// LAN CONTROL PROTOCOL setting — 4001 is only a placeholder.
func New(host string, port int) *Client {
if host == "" {
host = "127.0.0.1"
}
if port <= 0 || port > 65535 {
port = 4001
}
return &Client{Host: host, Port: port}
}
// NewSerial returns a Client talking over the ARCO's USB virtual COM port. The
// baud rate is irrelevant on USB per the ARCO manual (8N1 framing matters); we
// open at 9600 which also suits a real RS-232 hookup left at its default.
func NewSerial(comPort string) *Client {
return &Client{ComPort: comPort}
}
// roundTrip opens a connection (TCP or serial per the client's config), sends
// one CR-terminated command and (when wantReply) reads one CR/LF-terminated
// reply line.
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
var conn io.ReadWriteCloser
if c.ComPort != "" {
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: 9600})
if err != nil {
return "", fmt.Errorf("open ARCO %s: %w", c.ComPort, err)
}
_ = sp.SetReadTimeout(200 * time.Millisecond)
conn = sp
} else {
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
if err != nil {
return "", fmt.Errorf("connect ARCO %s:%d: %w", c.Host, c.Port, err)
}
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
conn = nc
}
defer conn.Close()
if _, err := conn.Write([]byte(cmd + "\r")); err != nil {
return "", fmt.Errorf("send %q: %w", cmd, err)
}
if !wantReply {
return "", nil
}
buf := make([]byte, 64)
var sb strings.Builder
deadline := time.Now().Add(ioTimeout)
for time.Now().Before(deadline) {
n, err := conn.Read(buf)
if n > 0 {
sb.Write(buf[:n])
if strings.ContainsAny(sb.String(), "\r\n") {
break
}
}
// A serial read that times out returns (0, nil) — keep polling until the
// overall deadline; a real error ends the read.
if err != nil {
break
}
}
line := strings.TrimSpace(sb.String())
if line == "" {
return "", fmt.Errorf("no reply to %q", cmd)
}
return line, nil
}
// GoTo points the antenna at the given azimuth (0-359). GS-232A takes M000-M450
// (overlap rotators accept >360); we normalise to [0,360).
func (c *Client) GoTo(az int) error {
az = ((az % 360) + 360) % 360
_, err := c.roundTrip(fmt.Sprintf("M%03d", az), false)
return err
}
// Stop interrupts any in-progress rotation.
func (c *Client) Stop() error {
_, err := c.roundTrip("S", false)
return err
}
// azRe matches both reply flavours: "+0aaa" (GS-232A) and "AZ=aaa" (GS-232B).
var azRe = regexp.MustCompile(`(?:\+0|AZ=)(\d{3})`)
// Heading queries the current azimuth. Returns the raw reply for diagnostics.
func (c *Client) Heading() (az int, raw string, err error) {
raw, err = c.roundTrip("C", true)
if err != nil {
return 0, raw, err
}
m := azRe.FindStringSubmatch(raw)
if m == nil {
return 0, raw, fmt.Errorf("unrecognised azimuth reply %q", raw)
}
az, _ = strconv.Atoi(m[1])
return az % 360, raw, nil
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.20.7" appVersion = "0.20.9"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.