Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa09251039 | |||
| d6626d96d0 | |||
| 8b831145ad | |||
| 81c60628c6 | |||
| 678787ec62 | |||
| 79dc20a859 | |||
| 824971d0a1 | |||
| 60bcd2422d | |||
| 9b0d7ce1dc |
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"hamlog/internal/adif"
|
||||
"hamlog/internal/antgenius"
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/audio"
|
||||
"hamlog/internal/award"
|
||||
@@ -25,23 +26,23 @@ import (
|
||||
"hamlog/internal/backup"
|
||||
"hamlog/internal/cat"
|
||||
"hamlog/internal/clublog"
|
||||
"hamlog/internal/cwdecode"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/cwdecode"
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/dxcc"
|
||||
"hamlog/internal/email"
|
||||
"hamlog/internal/extsvc"
|
||||
"hamlog/internal/integrations/udp"
|
||||
"hamlog/internal/lookup"
|
||||
"hamlog/internal/netctl"
|
||||
"hamlog/internal/operating"
|
||||
"hamlog/internal/pota"
|
||||
"hamlog/internal/powergenius"
|
||||
"hamlog/internal/profile"
|
||||
"hamlog/internal/qslcard"
|
||||
"hamlog/internal/qso"
|
||||
"hamlog/internal/rotator/pst"
|
||||
"hamlog/internal/settings"
|
||||
"hamlog/internal/antgenius"
|
||||
"hamlog/internal/powergenius"
|
||||
"hamlog/internal/ultrabeam"
|
||||
"hamlog/internal/winkeyer"
|
||||
|
||||
@@ -223,8 +224,8 @@ const (
|
||||
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
|
||||
keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert)
|
||||
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
|
||||
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags)
|
||||
keyExtLoTWUploadFlags = "extsvc.lotw.upload_flags" // CSV set of lotw_sent values to upload (e.g. "N,R")
|
||||
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags)
|
||||
keyExtLoTWUploadFlags = "extsvc.lotw.upload_flags" // CSV set of lotw_sent values to upload (e.g. "N,R")
|
||||
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
|
||||
keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload"
|
||||
keyExtLoTWUploadMode = "extsvc.lotw.upload_mode"
|
||||
@@ -374,57 +375,66 @@ type LookupSettings struct {
|
||||
|
||||
// App is the application context bound to the Wails runtime.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
db *sql.DB
|
||||
qso *qso.Repo
|
||||
settings *settings.Store
|
||||
profiles *profile.Repo
|
||||
lookup *lookup.Manager
|
||||
cache *lookup.Cache
|
||||
cat *cat.Manager
|
||||
dxcc *dxcc.Manager
|
||||
cluster *cluster.Manager
|
||||
pota *pota.Cache
|
||||
awardRefs *awardref.Repo
|
||||
qslTemplates *qslcard.Repo
|
||||
operating *operating.Repo
|
||||
udp *udp.Manager
|
||||
udpRepo *udp.Repo
|
||||
extsvc *extsvc.Manager
|
||||
winkeyer *winkeyer.Manager
|
||||
clublog *clublog.Manager
|
||||
ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled
|
||||
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
|
||||
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
||||
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
||||
audioMgr *audio.Manager
|
||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||
cwMu sync.Mutex // guards the CW decoder lifecycle
|
||||
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
||||
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
|
||||
cwPitchHz int // manual pitch override (0 = auto / follow Flex)
|
||||
startupProfile string // --profile <name> from the command line (activate at startup)
|
||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||
pttMu sync.Mutex
|
||||
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
||||
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
||||
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
|
||||
startupErr string // captured for surfacing to the frontend
|
||||
dbPath string // active database file (may be a user-chosen location)
|
||||
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
|
||||
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
|
||||
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
|
||||
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
|
||||
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
|
||||
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
|
||||
liveBand string
|
||||
liveMode string
|
||||
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
||||
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
||||
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
||||
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
|
||||
ctx context.Context
|
||||
db *sql.DB
|
||||
qso *qso.Repo
|
||||
settings *settings.Store
|
||||
profiles *profile.Repo
|
||||
lookup *lookup.Manager
|
||||
cache *lookup.Cache
|
||||
cat *cat.Manager
|
||||
dxcc *dxcc.Manager
|
||||
cluster *cluster.Manager
|
||||
pota *pota.Cache
|
||||
awardRefs *awardref.Repo
|
||||
qslTemplates *qslcard.Repo
|
||||
operating *operating.Repo
|
||||
udp *udp.Manager
|
||||
udpRepo *udp.Repo
|
||||
extsvc *extsvc.Manager
|
||||
winkeyer *winkeyer.Manager
|
||||
clublog *clublog.Manager
|
||||
ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled
|
||||
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
|
||||
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
||||
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
||||
audioMgr *audio.Manager
|
||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||
|
||||
// NET Control: persistent net definitions/rosters (global JSON) + the live
|
||||
// session (in-memory only — active stations currently in QSO).
|
||||
netStore *netctl.Store
|
||||
netMu sync.Mutex
|
||||
netOpenID string // id of the currently open net ("" = none)
|
||||
netActive []*qso.QSO // on-air QSO drafts (transient negative ids), check-in order
|
||||
netSeq int64 // transient-id counter for on-air drafts (decrements: -1, -2, …)
|
||||
|
||||
cwMu sync.Mutex // guards the CW decoder lifecycle
|
||||
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
||||
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
|
||||
cwPitchHz int // manual pitch override (0 = auto / follow Flex)
|
||||
startupProfile string // --profile <name> from the command line (activate at startup)
|
||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||
pttMu sync.Mutex
|
||||
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
||||
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
||||
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
|
||||
startupErr string // captured for surfacing to the frontend
|
||||
dbPath string // active database file (may be a user-chosen location)
|
||||
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
|
||||
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
|
||||
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
|
||||
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
|
||||
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
|
||||
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
|
||||
liveBand string
|
||||
liveMode string
|
||||
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
||||
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
||||
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
||||
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
|
||||
|
||||
// shuttingDown gates beforeClose re-entry: the first user attempt to
|
||||
// close fires shutdown tasks (backup, future LoTW upload, ...) while
|
||||
@@ -827,6 +837,13 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.qsoRec = audio.NewRecorder()
|
||||
a.startQSORecorderIfEnabled()
|
||||
|
||||
// NET Control store (global JSON, shared across logbooks).
|
||||
if ns, err := netctl.Open(filepath.Join(a.dataDir, "nets.json")); err != nil {
|
||||
applog.Printf("netctl: open failed: %v", err)
|
||||
} else {
|
||||
a.netStore = ns
|
||||
}
|
||||
|
||||
// Ultrabeam antenna: connect in the background if enabled.
|
||||
a.startUltrabeam()
|
||||
// Antenna Genius switch: connect in the background if enabled.
|
||||
@@ -1055,7 +1072,6 @@ func userDataDir() (string, error) {
|
||||
return filepath.Join(filepath.Dir(exe), "data"), nil
|
||||
}
|
||||
|
||||
|
||||
// fileExists reports whether path exists and is a regular file.
|
||||
func fileExists(path string) bool {
|
||||
fi, err := os.Stat(path)
|
||||
@@ -1114,8 +1130,8 @@ func copyFileData(src, dst string) error {
|
||||
// (MySQL). The MySQL connection lives here — not in the settings table — for
|
||||
// the same reason: we need it to choose and open the backend at startup.
|
||||
type dbPointer struct {
|
||||
DBPath string `json:"db_path"`
|
||||
MySQL *MySQLSettings `json:"mysql,omitempty"`
|
||||
DBPath string `json:"db_path"`
|
||||
MySQL *MySQLSettings `json:"mysql,omitempty"`
|
||||
}
|
||||
|
||||
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
|
||||
@@ -1181,9 +1197,9 @@ type MySQLSettings struct {
|
||||
// the Settings UI can confirm the shared MySQL connection (or explain a
|
||||
// fallback to SQLite when the configured server was unreachable).
|
||||
type DBBackendStatus struct {
|
||||
Active string `json:"active"` // "sqlite" | "mysql"
|
||||
Fallback bool `json:"fallback"` // MySQL was enabled but failed, so we used SQLite
|
||||
Error string `json:"error"` // the MySQL open error, when Fallback is true
|
||||
Active string `json:"active"` // "sqlite" | "mysql"
|
||||
Fallback bool `json:"fallback"` // MySQL was enabled but failed, so we used SQLite
|
||||
Error string `json:"error"` // the MySQL open error, when Fallback is true
|
||||
}
|
||||
|
||||
// GetDBBackendStatus returns the active backend and any MySQL fallback error.
|
||||
@@ -1703,6 +1719,20 @@ func (a *App) applyStationDefaults(q *qso.QSO, includeIdentity bool) {
|
||||
if q.MyPOTARef == "" {
|
||||
q.MyPOTARef = p.MyPOTARef
|
||||
}
|
||||
// Per-band rig/antenna from Operating conditions (the antenna ticked as
|
||||
// DEFAULT for this band) — the same auto-fill the entry strip does, applied
|
||||
// here so imported QSOs get MY_RIG / MY_ANTENNA from the band defaults.
|
||||
if a.operating != nil && q.Band != "" && (q.MyRig == "" || q.MyAntenna == "") {
|
||||
if d, ok, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok {
|
||||
if q.MyRig == "" {
|
||||
q.MyRig = d.StationName
|
||||
}
|
||||
if q.MyAntenna == "" {
|
||||
q.MyAntenna = d.AntennaName
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to the profile's static rig/antenna for anything still empty.
|
||||
if q.MyRig == "" {
|
||||
q.MyRig = p.MyRig
|
||||
}
|
||||
@@ -3569,6 +3599,9 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStatio
|
||||
// Backfill empty MY_* descriptive fields from the active profile
|
||||
// (identity fields left alone to keep mixed-call routing intact).
|
||||
a.applyStationDefaults(q, false)
|
||||
// Also stamp the default QSL/LoTW/eQSL confirmation statuses on
|
||||
// any that are still empty (same defaults new QSOs get).
|
||||
a.applyQSLDefaults(q)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4220,6 +4253,18 @@ func (a *App) QSOAudioRestart() bool {
|
||||
return a.qsoRec.Active()
|
||||
}
|
||||
|
||||
// QSOAudioResetClock restarts the in-progress recording from zero, dropping
|
||||
// everything captured so far (pre-roll included). Lets the operator click the
|
||||
// REC timer to record only their own exchange when the station was already in a
|
||||
// long QSO before they entered the call. Returns whether a recording is active.
|
||||
func (a *App) QSOAudioResetClock() bool {
|
||||
if a.qsoRec == nil {
|
||||
return false
|
||||
}
|
||||
a.qsoRec.ResetQSOClock()
|
||||
return a.qsoRec.Active()
|
||||
}
|
||||
|
||||
// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
|
||||
// abandoned without logging).
|
||||
func (a *App) QSOAudioCancel() {
|
||||
@@ -4231,6 +4276,284 @@ func (a *App) QSOAudioCancel() {
|
||||
// RestartQSORecorder applies new audio settings to the running recorder.
|
||||
func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() }
|
||||
|
||||
// ── NET Control ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A NET is a named net with a station roster (persisted globally in nets.json).
|
||||
// Opening a net starts an in-memory live session: stations moved "on the air"
|
||||
// (NetActivate) accumulate a time_on; moving one back off (NetDeactivate) logs
|
||||
// the QSO into the active logbook with live CAT freq/mode and removes it from
|
||||
// the session. The session is RAM-only — closing the app mid-net drops any
|
||||
// active stations that were never logged.
|
||||
|
||||
// NetList returns all nets (with rosters), ordered by name.
|
||||
func (a *App) NetList() []netctl.Net {
|
||||
if a.netStore == nil {
|
||||
return nil
|
||||
}
|
||||
return a.netStore.List()
|
||||
}
|
||||
|
||||
// NetCreate adds a new named net (defaults 59/59).
|
||||
func (a *App) NetCreate(name string) (netctl.Net, error) {
|
||||
if a.netStore == nil {
|
||||
return netctl.Net{}, fmt.Errorf("net store unavailable")
|
||||
}
|
||||
return a.netStore.Create(name)
|
||||
}
|
||||
|
||||
// NetRename renames a net.
|
||||
func (a *App) NetRename(id, name string) error {
|
||||
if a.netStore == nil {
|
||||
return fmt.Errorf("net store unavailable")
|
||||
}
|
||||
return a.netStore.Rename(id, name)
|
||||
}
|
||||
|
||||
// NetSetDefaults updates a net's default report/comment values.
|
||||
func (a *App) NetSetDefaults(id, rstSent, rstRcvd, comment string) error {
|
||||
if a.netStore == nil {
|
||||
return fmt.Errorf("net store unavailable")
|
||||
}
|
||||
return a.netStore.SetDefaults(id, rstSent, rstRcvd, comment)
|
||||
}
|
||||
|
||||
// NetDelete removes a net and its roster (closing it first if it's open).
|
||||
func (a *App) NetDelete(id string) error {
|
||||
if a.netStore == nil {
|
||||
return fmt.Errorf("net store unavailable")
|
||||
}
|
||||
a.netMu.Lock()
|
||||
if a.netOpenID == id {
|
||||
a.netOpenID, a.netActive = "", nil
|
||||
}
|
||||
a.netMu.Unlock()
|
||||
return a.netStore.Delete(id)
|
||||
}
|
||||
|
||||
// NetRoster returns a net's roster, sorted by callsign.
|
||||
func (a *App) NetRoster(id string) ([]netctl.Station, error) {
|
||||
if a.netStore == nil {
|
||||
return nil, fmt.Errorf("net store unavailable")
|
||||
}
|
||||
return a.netStore.Roster(id)
|
||||
}
|
||||
|
||||
// NetRosterUpsert adds or updates a roster station.
|
||||
func (a *App) NetRosterUpsert(id string, s netctl.Station) error {
|
||||
if a.netStore == nil {
|
||||
return fmt.Errorf("net store unavailable")
|
||||
}
|
||||
return a.netStore.RosterUpsert(id, s)
|
||||
}
|
||||
|
||||
// NetRosterRemove deletes a callsign from a net's roster.
|
||||
func (a *App) NetRosterRemove(id, callsign string) error {
|
||||
if a.netStore == nil {
|
||||
return fmt.Errorf("net store unavailable")
|
||||
}
|
||||
return a.netStore.RosterRemove(id, callsign)
|
||||
}
|
||||
|
||||
// NetLookup resolves a callsign via the configured provider (QRZ) so the
|
||||
// Add-contact dialog can pre-fill name/QTH/country/zones. Best-effort — returns
|
||||
// just the callsign if no provider or no match.
|
||||
func (a *App) NetLookup(callsign string) netctl.Station {
|
||||
st := netctl.Station{Callsign: strings.ToUpper(strings.TrimSpace(callsign))}
|
||||
if a.lookup == nil || st.Callsign == "" {
|
||||
return st
|
||||
}
|
||||
if lr, err := a.lookup.Lookup(a.ctx, st.Callsign); err == nil {
|
||||
st.Name, st.QTH, st.Country = lr.Name, lr.QTH, lr.Country
|
||||
st.DXCC, st.CQ, st.ITU = lr.DXCC, lr.CQZ, lr.ITUZ
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// NetOpen starts a live session for the given net (clears any prior session).
|
||||
func (a *App) NetOpen(id string) error {
|
||||
if a.netStore == nil {
|
||||
return fmt.Errorf("net store unavailable")
|
||||
}
|
||||
if _, ok := a.netStore.Get(id); !ok {
|
||||
return fmt.Errorf("net not found")
|
||||
}
|
||||
a.netMu.Lock()
|
||||
a.netOpenID, a.netActive = id, nil
|
||||
a.netMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// NetClose ends the live session (does NOT log remaining actives — the operator
|
||||
// moves each station back to the roster side to log it before closing).
|
||||
func (a *App) NetClose() {
|
||||
a.netMu.Lock()
|
||||
a.netOpenID, a.netActive = "", nil
|
||||
a.netMu.Unlock()
|
||||
}
|
||||
|
||||
// NetOpenID returns the id of the currently open net ("" = none).
|
||||
func (a *App) NetOpenID() string {
|
||||
a.netMu.Lock()
|
||||
defer a.netMu.Unlock()
|
||||
return a.netOpenID
|
||||
}
|
||||
|
||||
// NetActiveList returns the stations currently on the air, in check-in order.
|
||||
// Each is a full QSO *draft* (not yet in the DB) carrying a negative transient
|
||||
// id so the same QSOEditModal as Recent QSOs can edit every field.
|
||||
func (a *App) NetActiveList() []qso.QSO {
|
||||
a.netMu.Lock()
|
||||
defer a.netMu.Unlock()
|
||||
out := make([]qso.QSO, len(a.netActive))
|
||||
for i, e := range a.netActive {
|
||||
out[i] = *e
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// netLiveFreq returns the rig's live freq/band/mode, falling back to the last
|
||||
// UI-reported values when CAT is off.
|
||||
func (a *App) netLiveFreq() (freq int64, band, mode string) {
|
||||
var st cat.RigState
|
||||
if a.cat != nil {
|
||||
st = a.cat.State()
|
||||
}
|
||||
freq, band, mode = st.FreqHz, st.Band, st.Mode
|
||||
if freq == 0 {
|
||||
a.liveActMu.Lock()
|
||||
freq, band, mode = a.liveFreqHz, a.liveBand, a.liveMode
|
||||
a.liveActMu.Unlock()
|
||||
}
|
||||
if band == "" && freq > 0 {
|
||||
band = bandForHz(freq)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// NetActivate puts a station on the air: it builds a QSO draft (time_on now,
|
||||
// live freq/mode, defaults + roster info) with a transient negative id and
|
||||
// returns it. No-op (returns the existing draft) if already active.
|
||||
func (a *App) NetActivate(callsign string) (qso.QSO, error) {
|
||||
call := strings.ToUpper(strings.TrimSpace(callsign))
|
||||
if call == "" {
|
||||
return qso.QSO{}, fmt.Errorf("callsign required")
|
||||
}
|
||||
a.netMu.Lock()
|
||||
defer a.netMu.Unlock()
|
||||
if a.netOpenID == "" {
|
||||
return qso.QSO{}, fmt.Errorf("no net open")
|
||||
}
|
||||
for _, e := range a.netActive {
|
||||
if strings.EqualFold(e.Callsign, call) {
|
||||
return *e, nil // already on the air
|
||||
}
|
||||
}
|
||||
a.netSeq--
|
||||
q := &qso.QSO{ID: a.netSeq, Callsign: call, QSODate: time.Now().UTC()}
|
||||
if net, ok := a.netStore.Get(a.netOpenID); ok {
|
||||
q.RSTSent, q.RSTRcvd, q.Comment = net.DefaultRSTSent, net.DefaultRSTRcvd, net.DefaultComment
|
||||
for _, st := range net.Stations {
|
||||
if strings.EqualFold(st.Callsign, call) {
|
||||
q.Name, q.QTH, q.Country = st.Name, st.QTH, st.Country
|
||||
if st.DXCC != 0 {
|
||||
d := st.DXCC
|
||||
q.DXCC = &d
|
||||
}
|
||||
if st.CQ != 0 {
|
||||
c := st.CQ
|
||||
q.CQZ = &c
|
||||
}
|
||||
if st.ITU != 0 {
|
||||
i := st.ITU
|
||||
q.ITUZ = &i
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if q.RSTSent == "" {
|
||||
q.RSTSent = "59"
|
||||
}
|
||||
if q.RSTRcvd == "" {
|
||||
q.RSTRcvd = "59"
|
||||
}
|
||||
freq, band, mode := a.netLiveFreq()
|
||||
q.Band, q.Mode = band, mode
|
||||
if freq > 0 {
|
||||
f := freq
|
||||
q.FreqHz = &f
|
||||
}
|
||||
a.applyDXCCNumber(q) // fill country/dxcc/zones for display
|
||||
a.refineDistrictZones(q)
|
||||
a.netActive = append(a.netActive, q)
|
||||
return *q, nil
|
||||
}
|
||||
|
||||
// NetUpdateActive replaces an on-air QSO draft (matched by its transient id)
|
||||
// with the edited version from the QSOEditModal. Lets the operator change every
|
||||
// field of a station before it's logged.
|
||||
func (a *App) NetUpdateActive(q qso.QSO) error {
|
||||
a.netMu.Lock()
|
||||
defer a.netMu.Unlock()
|
||||
for i, cur := range a.netActive {
|
||||
if cur.ID == q.ID {
|
||||
qq := q
|
||||
a.netActive[i] = &qq
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("station not active")
|
||||
}
|
||||
|
||||
// NetDiscardActive removes an on-air draft (by transient id) WITHOUT logging it
|
||||
// — i.e. cancel a station added by mistake (the modal's Delete button).
|
||||
func (a *App) NetDiscardActive(id int64) error {
|
||||
a.netMu.Lock()
|
||||
defer a.netMu.Unlock()
|
||||
for i, e := range a.netActive {
|
||||
if e.ID == id {
|
||||
a.netActive = append(a.netActive[:i], a.netActive[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NetDeactivate ends a station's QSO (by transient id): it logs the draft to the
|
||||
// active logbook (time_off = now; freq/mode refreshed from the rig only if the
|
||||
// draft still has none, so manual edits are respected) and removes it from the
|
||||
// session. Returns the new QSO id.
|
||||
func (a *App) NetDeactivate(id int64) (int64, error) {
|
||||
a.netMu.Lock()
|
||||
var draft *qso.QSO
|
||||
idx := -1
|
||||
for i, e := range a.netActive {
|
||||
if e.ID == id {
|
||||
draft, idx = e, i
|
||||
break
|
||||
}
|
||||
}
|
||||
if draft == nil {
|
||||
a.netMu.Unlock()
|
||||
return 0, fmt.Errorf("station not active")
|
||||
}
|
||||
a.netActive = append(a.netActive[:idx], a.netActive[idx+1:]...)
|
||||
a.netMu.Unlock()
|
||||
|
||||
q := *draft
|
||||
q.ID = 0 // transient id must not reach the DB (AddQSO inserts a fresh row)
|
||||
q.QSODateOff = time.Now().UTC()
|
||||
if q.FreqHz == nil && q.Band == "" {
|
||||
freq, band, mode := a.netLiveFreq()
|
||||
q.Band, q.Mode = band, mode
|
||||
if freq > 0 {
|
||||
f := freq
|
||||
q.FreqHz = &f
|
||||
}
|
||||
}
|
||||
return a.AddQSO(q)
|
||||
}
|
||||
|
||||
// ── E-mail / SMTP (send QSO recordings) ───────────────────────────────
|
||||
|
||||
const (
|
||||
@@ -8480,7 +8803,8 @@ type SpotQuery struct {
|
||||
//
|
||||
// "new" — entity never worked
|
||||
// "new-band" — entity worked but never on this band
|
||||
// "new-slot" — entity worked on this band but not in this mode
|
||||
// "new-mode" — band worked, but this MODE never worked on the entity (any band)
|
||||
// "new-slot" — band & mode each worked before, but not this band+mode together
|
||||
// "worked" — exact band+mode already in the log
|
||||
// "" — couldn't resolve the entity (no cty.dat match)
|
||||
type SpotStatus struct {
|
||||
@@ -8569,12 +8893,19 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
||||
out[i].Status = "new-band"
|
||||
continue
|
||||
}
|
||||
// Without a mode we can't distinguish "new slot" from "worked";
|
||||
// Without a mode we can't distinguish the rest from "worked";
|
||||
// the safer default is "worked" so we never falsely claim "new".
|
||||
if out[i].Mode == "" {
|
||||
out[i].Status = "worked"
|
||||
continue
|
||||
}
|
||||
// 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+mode → new-slot. Otherwise → worked.
|
||||
if _, m := e.Modes[out[i].Mode]; !m {
|
||||
out[i].Status = "new-mode"
|
||||
continue
|
||||
}
|
||||
if _, ok := e.Slots[out[i].Band][out[i].Mode]; !ok {
|
||||
out[i].Status = "new-slot"
|
||||
continue
|
||||
|
||||
+51
-10
@@ -32,7 +32,7 @@ import {
|
||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart,
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||
GetAwardDefs,
|
||||
GetUIPref,
|
||||
ReportLiveActivity,
|
||||
@@ -66,6 +66,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||
import { NetControlPanel } from '@/components/NetControlPanel';
|
||||
import { BulkEditModal } from '@/components/BulkEditModal';
|
||||
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
|
||||
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||
@@ -507,6 +508,12 @@ export default function App() {
|
||||
if (forCall !== undefined) recordingCallRef.current = forCall.trim().toUpperCase();
|
||||
QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
||||
};
|
||||
// Reset the recording to zero (drop everything so far, pre-roll included) —
|
||||
// bound to clicking the REC timer. Use when the station was already in a long
|
||||
// QSO and you only want your own exchange in the file.
|
||||
const resetRecordingClock = () => {
|
||||
QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
|
||||
};
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [filterCallsign, setFilterCallsign] = useState('');
|
||||
// Advanced filter builder (replaces the old band/mode dropdowns).
|
||||
@@ -689,6 +696,10 @@ export default function App() {
|
||||
}
|
||||
const chatShown = chatOpen && chatAvailable;
|
||||
|
||||
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
|
||||
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
|
||||
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
|
||||
|
||||
const [dvkEnabled, setDvkEnabled] = useState(false);
|
||||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||
@@ -729,7 +740,7 @@ export default function App() {
|
||||
const [clusterLockMode, setClusterLockMode] = useState(false);
|
||||
// Status filter chips. Empty set = show every status (including
|
||||
// already-worked). Otherwise only matching spots pass.
|
||||
type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked';
|
||||
type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
|
||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
|
||||
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||
@@ -1703,8 +1714,6 @@ export default function App() {
|
||||
};
|
||||
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
|
||||
await AddQSO(payload);
|
||||
// Same green toast as a QSL upload, so the op gets visual confirmation.
|
||||
showToast(`QSO logged — ${payload.callsign}${band ? ` · ${band}` : ''}${mode ? ` ${mode}` : ''}`);
|
||||
resetEntry(); // clears the call AND the Worked-before matrix
|
||||
callsignRef.current?.focus(); // return focus to the call field, wherever it was (e.g. Name)
|
||||
await refresh();
|
||||
@@ -2098,6 +2107,8 @@ export default function App() {
|
||||
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
|
||||
{ type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: netEnabled ? '✓ NET Control' : 'NET Control', action: 'tools.net' },
|
||||
{ type: 'separator' },
|
||||
// Maintenance — bumped here while we only have one entry. Will move
|
||||
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
|
||||
{ type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
|
||||
@@ -2106,7 +2117,7 @@ export default function App() {
|
||||
{ name: 'help', label: 'Help', items: [
|
||||
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
|
||||
]},
|
||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled]);
|
||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled]);
|
||||
|
||||
function handleMenu(action: string) {
|
||||
switch (action) {
|
||||
@@ -2124,6 +2135,7 @@ export default function App() {
|
||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
case 'tools.cwdecoder': toggleCwDecoder(); break;
|
||||
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
|
||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||
case 'tools.downloadRefs': downloadRefs(); break;
|
||||
case 'help.about': setShowAbout(true); break;
|
||||
@@ -2247,10 +2259,16 @@ export default function App() {
|
||||
</Label>
|
||||
<div className="relative">
|
||||
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-red-600 whitespace-nowrap pointer-events-none">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={resetRecordingClock}
|
||||
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-red-600 whitespace-nowrap cursor-pointer rounded px-1 hover:bg-red-50"
|
||||
>
|
||||
<span className="size-2 rounded-full bg-red-600 animate-pulse" />
|
||||
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<Input
|
||||
ref={callsignRef}
|
||||
@@ -2638,8 +2656,9 @@ export default function App() {
|
||||
{([
|
||||
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
|
||||
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
|
||||
{ k: 'new-mode' as SpotStatusKey, label: 'NEW MODE', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
||||
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
||||
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' },
|
||||
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
||||
]).map((s) => {
|
||||
const on = clusterStatusFilter.has(s.k);
|
||||
return (
|
||||
@@ -2736,7 +2755,7 @@ export default function App() {
|
||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
|
||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
|
||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
|
||||
</div>
|
||||
);
|
||||
case 'flex':
|
||||
@@ -3448,6 +3467,21 @@ export default function App() {
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="awards">Awards</TabsTrigger>
|
||||
<TabsTrigger value="bandmap">Band Map</TabsTrigger>
|
||||
{netEnabled && (
|
||||
<TabsTrigger value="net" className="gap-1.5">
|
||||
Net
|
||||
<span
|
||||
role="button"
|
||||
aria-label="Close Net"
|
||||
title="Close"
|
||||
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||
onClick={(e) => { e.stopPropagation(); setNetEnabled(false); setActiveTab((t) => (t === 'net' ? 'recent' : t)); }}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
|
||||
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
|
||||
{/* Not a tab — QRZ blocks embedding, so this opens the call's
|
||||
@@ -3554,6 +3588,7 @@ export default function App() {
|
||||
onBulkEdit={openBulkEdit}
|
||||
onExportSelected={exportSelectedADIF}
|
||||
onExportFiltered={exportFilteredADIF}
|
||||
onDelete={(ids) => setDeletingIds(ids)}
|
||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||
/>
|
||||
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
||||
@@ -3729,7 +3764,7 @@ export default function App() {
|
||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Opened on demand from Tools → QSL Manager; closable via the
|
||||
@@ -3774,6 +3809,12 @@ export default function App() {
|
||||
{/* Band Map: several bands shown side-by-side (panadapter-style
|
||||
strips). Pick bands with the chips; each strip is clickable to
|
||||
tune the rig. */}
|
||||
{netEnabled && (
|
||||
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground mr-1">Bands:</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
|
||||
@@ -121,6 +121,7 @@ function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string
|
||||
switch (s?.status) {
|
||||
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
|
||||
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
|
||||
case 'new-mode': return { text: 'NEW MODE', fg: '#854d0e', bg: '#fef08a' };
|
||||
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
|
||||
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
|
||||
}
|
||||
@@ -159,6 +160,7 @@ const COL_CATALOG: ColEntry[] = [
|
||||
const s = statusFor(p);
|
||||
if (s?.status === 'new') return 'NEW DXCC';
|
||||
if (s?.status === 'new-band') return 'NEW BAND';
|
||||
if (s?.status === 'new-mode') return 'NEW MODE';
|
||||
if (s?.status === 'new-slot') return 'NEW SLOT';
|
||||
return s?.worked_call ? 'WKD CALL' : '';
|
||||
},
|
||||
@@ -212,12 +214,22 @@ const COL_CATALOG: ColEntry[] = [
|
||||
defaultVisible: true,
|
||||
cellClass: 'font-mono',
|
||||
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
|
||||
// NEW SLOT (mode not yet worked on this band) → fill the cell.
|
||||
cellStyle: (p: any) => (statusFor(p)?.status === 'new-slot'
|
||||
? { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 }
|
||||
: undefined),
|
||||
// Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
|
||||
// yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
|
||||
cellStyle: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
|
||||
// Status badge text tells them apart.
|
||||
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
|
||||
return undefined;
|
||||
},
|
||||
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}>—</span>,
|
||||
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-slot' ? 'NEW SLOT (mode not yet worked on this band)' : undefined),
|
||||
tooltipValueGetter: (p: any) => {
|
||||
const st = statusFor(p)?.status;
|
||||
if (st === 'new-mode') return 'NEW MODE (this mode never worked on this entity)';
|
||||
if (st === 'new-slot') return 'NEW SLOT (this band+mode not yet worked)';
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
group: 'Spot', label: 'Pfx', colId: 'pfx',
|
||||
@@ -327,6 +339,14 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
||||
// change below.
|
||||
const context = useMemo(() => ({ spotStatus }), [spotStatus]);
|
||||
|
||||
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
|
||||
// Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid
|
||||
// won't re-render those cells on its own — force a refresh so e.g. a worked call
|
||||
// turns blue once its status loads.
|
||||
useEffect(() => {
|
||||
gridRef.current?.api?.refreshCells({ force: true });
|
||||
}, [spotStatus]);
|
||||
|
||||
function onGridReady(e: GridReadyEvent) {
|
||||
const local = loadLocal(COL_STATE_KEY);
|
||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
|
||||
@@ -238,8 +238,15 @@ export function FlexPanel() {
|
||||
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
||||
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
||||
const CW_BW = [100, 200, 300, 400, 500];
|
||||
const SSB_BW = [1800, 2100, 2400, 2800, 3000, 4000, 6000];
|
||||
const SSB_BW = [1800, 2100, 2400, 2700, 3000, 4000, 6000];
|
||||
const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0));
|
||||
// Highlight the preset CLOSEST to the radio's actual filter width. The rig
|
||||
// rarely reports a width that lands exactly on a preset (e.g. 2.7k presets as
|
||||
// 100–2790), so an exact/±50 match would leave nothing lit — "doesn't pick up
|
||||
// the current filter". Snapping to the nearest preset always reflects the rig.
|
||||
const ssbActiveBW = curBW > 0
|
||||
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
|
||||
: -1;
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||
@@ -416,7 +423,7 @@ export function FlexPanel() {
|
||||
FlexSetFilter(lo, hi).catch(() => {});
|
||||
}}
|
||||
className={cn('px-1.5 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
|
||||
Math.abs(curBW - bw) <= 50 ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
bw === ssbActiveBW ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{(bw / 1000).toFixed(1)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -139,18 +139,13 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
const from = gridToLatLon(fromGrid);
|
||||
const to = gridToLatLon(toGrid);
|
||||
|
||||
if (from && to) {
|
||||
// Station marker + antenna beam/boom are drawn whenever the station grid is
|
||||
// known — independent of any DX. The antenna is always pointed somewhere, so
|
||||
// the beam heading should show even before a callsign is entered.
|
||||
if (from) {
|
||||
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
|
||||
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||
.addTo(wo);
|
||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||
.addTo(wo);
|
||||
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
|
||||
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the
|
||||
// line, which makes a smooth arc look angular/bumpy).
|
||||
L.polyline(unwrapLon(pts) as L.LatLngExpression[],
|
||||
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
|
||||
|
||||
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
||||
if (beamAzimuths && beamAzimuths.length) {
|
||||
@@ -204,16 +199,30 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 })
|
||||
.bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||
}
|
||||
}
|
||||
|
||||
if (autoZoom) {
|
||||
// DX marker + great-circle arc — only when a DX grid is known (callsign entered).
|
||||
let arcPts: [number, number][] | null = null;
|
||||
if (from && to) {
|
||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||
.addTo(wo);
|
||||
arcPts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
|
||||
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the line).
|
||||
L.polyline(unwrapLon(arcPts) as L.LatLngExpression[],
|
||||
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
|
||||
}
|
||||
|
||||
if (autoZoom) {
|
||||
if (from && to && arcPts) {
|
||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||
pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||
} else if (to) {
|
||||
wm.setView([to.lat, to.lon], 3);
|
||||
} else if (from) {
|
||||
wm.setView([from.lat, from.lon], 3);
|
||||
}
|
||||
} else if (autoZoom && to) {
|
||||
wm.setView([to.lat, to.lon], 3);
|
||||
} else if (autoZoom && from) {
|
||||
wm.setView([from.lat, from.lon], 3);
|
||||
}
|
||||
setTimeout(() => { wm.invalidateSize(); }, 0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||
type ColDef,
|
||||
} from 'ag-grid-community';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
import type { QSOForm } from '@/types';
|
||||
import {
|
||||
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
||||
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
||||
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
|
||||
} from '@/../wailsjs/go/main/App';
|
||||
import { netctl } from '@/../wailsjs/go/models';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
const hamlogTheme = themeQuartz.withParams({
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12.5,
|
||||
backgroundColor: '#faf6ea',
|
||||
foregroundColor: '#2a2419',
|
||||
headerBackgroundColor: '#e8dfc9',
|
||||
headerTextColor: '#5a4f3a',
|
||||
headerFontWeight: 600,
|
||||
oddRowBackgroundColor: '#f5efe0',
|
||||
rowHoverColor: '#ecdcb4',
|
||||
selectedRowBackgroundColor: '#f0d9a8',
|
||||
borderColor: '#c8b994',
|
||||
rowBorder: { color: '#d8c9a8', width: 1 },
|
||||
columnBorder: { color: '#d8c9a8', width: 1 },
|
||||
cellHorizontalPadding: 10,
|
||||
rowHeight: 30,
|
||||
headerHeight: 32,
|
||||
spacing: 4,
|
||||
accentColor: '#b8410c',
|
||||
iconSize: 12,
|
||||
});
|
||||
|
||||
type Net = netctl.Net;
|
||||
type Station = netctl.Station;
|
||||
|
||||
function fmtTimeOn(s: any): string {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}Z`;
|
||||
}
|
||||
|
||||
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
|
||||
|
||||
type Props = {
|
||||
onLogged?: () => void;
|
||||
countries?: string[];
|
||||
bands?: string[];
|
||||
modes?: string[];
|
||||
};
|
||||
|
||||
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
||||
const [nets, setNets] = useState<Net[]>([]);
|
||||
const [selId, setSelId] = useState<string>('');
|
||||
const [openId, setOpenId] = useState<string>('');
|
||||
const [roster, setRoster] = useState<Station[]>([]);
|
||||
const [active, setActive] = useState<QSOForm[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
||||
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
||||
|
||||
// Add/edit-contact dialog.
|
||||
const [contactOpen, setContactOpen] = useState(false);
|
||||
const [contact, setContact] = useState<Station>(emptyStation());
|
||||
const [looking, setLooking] = useState(false);
|
||||
|
||||
const activeGrid = useRef<any>(null);
|
||||
const rosterGrid = useRef<any>(null);
|
||||
|
||||
const isOpen = openId !== '' && openId === selId;
|
||||
|
||||
const refreshNets = useCallback(async () => {
|
||||
try {
|
||||
const [list, oid] = await Promise.all([NetList(), NetOpenID()]);
|
||||
const arr = (list ?? []) as Net[];
|
||||
setNets(arr);
|
||||
setOpenId(oid ?? '');
|
||||
setSelId((cur) => cur || (oid ?? '') || (arr[0]?.id ?? ''));
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}, []);
|
||||
|
||||
const refreshRoster = useCallback(async (id: string) => {
|
||||
if (!id) { setRoster([]); return; }
|
||||
try { setRoster(((await NetRoster(id)) ?? []) as Station[]); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}, []);
|
||||
|
||||
const refreshActive = useCallback(async () => {
|
||||
try { setActive(((await NetActiveList()) ?? []) as unknown as QSOForm[]); }
|
||||
catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refreshNets(); }, [refreshNets]);
|
||||
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
|
||||
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
|
||||
|
||||
// The roster side hides callsigns currently on the air (they live in the left
|
||||
// grid until logged), mirroring Log4OM's two-list behaviour.
|
||||
const activeCalls = useMemo(
|
||||
() => new Set(active.map((a) => (a.callsign ?? '').toUpperCase())),
|
||||
[active],
|
||||
);
|
||||
const rosterShown = useMemo(
|
||||
() => roster.filter((s) => !activeCalls.has((s.callsign ?? '').toUpperCase())),
|
||||
[roster, activeCalls],
|
||||
);
|
||||
|
||||
async function newNet() {
|
||||
const name = window.prompt('New NET name:');
|
||||
if (!name?.trim()) return;
|
||||
try { const n = await NetCreate(name.trim()); await refreshNets(); setSelId(n.id); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function renameNet() {
|
||||
if (!selId) return;
|
||||
const cur = nets.find((n) => n.id === selId);
|
||||
const name = window.prompt('Rename NET:', cur?.name ?? '');
|
||||
if (!name?.trim()) return;
|
||||
try { await NetRename(selId, name.trim()); await refreshNets(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function deleteNet() {
|
||||
if (!selId) return;
|
||||
const cur = nets.find((n) => n.id === selId);
|
||||
if (!window.confirm(`Delete NET "${cur?.name}" and its roster? This cannot be undone.`)) return;
|
||||
try { await NetDelete(selId); setSelId(''); await refreshNets(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function toggleOpen() {
|
||||
try {
|
||||
if (isOpen) {
|
||||
if (active.length > 0 &&
|
||||
!window.confirm(`${active.length} station(s) still on the air will be dropped WITHOUT logging. Close anyway?`)) return;
|
||||
await NetClose(); setOpenId('');
|
||||
} else {
|
||||
await NetOpen(selId); setOpenId(selId); await refreshActive();
|
||||
}
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
// Roster → active (start QSO).
|
||||
async function activate(call: string) {
|
||||
if (!isOpen || !call) return;
|
||||
try { await NetActivate(call); await refreshActive(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
// Active → logged (end QSO, removed from session, written to the logbook).
|
||||
async function deactivate(id?: number) {
|
||||
if (id == null) return;
|
||||
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
||||
async function saveDraft(q: QSOForm) {
|
||||
try { await NetUpdateActive(q as any); setEditingDraft(null); await refreshActive(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function discardDraft(id: number) {
|
||||
try { await NetDiscardActive(id); setEditingDraft(null); await refreshActive(); }
|
||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
// Add-contact dialog.
|
||||
function openAddContact() { setContact(emptyStation()); setContactOpen(true); }
|
||||
async function lookupContact() {
|
||||
const call = (contact.callsign ?? '').trim();
|
||||
if (!call) return;
|
||||
setLooking(true);
|
||||
try {
|
||||
const r = await NetLookup(call);
|
||||
setContact((c) => netctl.Station.createFrom({
|
||||
...c, callsign: (r.callsign || call).toUpperCase(),
|
||||
name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country,
|
||||
dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq,
|
||||
}));
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
finally { setLooking(false); }
|
||||
}
|
||||
async function saveContact() {
|
||||
const call = (contact.callsign ?? '').trim().toUpperCase();
|
||||
if (!call || !selId) return;
|
||||
try {
|
||||
await NetRosterUpsert(selId, netctl.Station.createFrom({ ...contact, callsign: call }));
|
||||
setContactOpen(false);
|
||||
await refreshRoster(selId);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
async function removeSelectedRoster() {
|
||||
const sel = (rosterGrid.current?.api?.getSelectedRows() ?? []) as Station[];
|
||||
if (sel.length === 0) return;
|
||||
if (!window.confirm(`Remove ${sel.length} station(s) from this NET's roster?`)) return;
|
||||
try {
|
||||
for (const s of sel) await NetRosterRemove(selId, s.callsign);
|
||||
await refreshRoster(selId);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
|
||||
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
{ headerName: 'Name', field: 'name', flex: 1 },
|
||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||
{ headerName: 'Time on', valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
|
||||
{ headerName: 'Band', field: 'band', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'Mode', field: 'mode', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
|
||||
{ headerName: 'Comment', field: 'comment', flex: 1.5 },
|
||||
], []);
|
||||
|
||||
const rosterCols = useMemo<ColDef<Station>[]>(() => [
|
||||
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
|
||||
{ headerName: 'Name', field: 'name', flex: 1 },
|
||||
{ headerName: 'QTH', field: 'qth', flex: 1 },
|
||||
{ headerName: 'Country', field: 'country', width: 130 },
|
||||
], []);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({ sortable: true, resizable: true, suppressMovable: false }), []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={newNet}>
|
||||
<Plus className="size-3.5" /> New NET
|
||||
</Button>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm min-w-[180px]"
|
||||
value={selId}
|
||||
disabled={isOpen}
|
||||
onChange={(e) => setSelId(e.target.value)}
|
||||
title={isOpen ? 'Close the NET to switch' : 'Select a NET'}
|
||||
>
|
||||
<option value="">— select a NET —</option>
|
||||
{nets.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
|
||||
</select>
|
||||
<Button variant={isOpen ? 'destructive' : 'default'} size="sm" className="h-8" disabled={!selId} onClick={toggleOpen}>
|
||||
<Radio className="size-3.5" /> {isOpen ? 'Close NET' : 'Open NET'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>Rename</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-rose-700" disabled={!selId || isOpen} onClick={deleteNet}>
|
||||
<Trash2 className="size-3.5" /> Delete
|
||||
</Button>
|
||||
|
||||
<div className="flex-1" />
|
||||
{isOpen && <Badge className="bg-emerald-600 text-white tracking-wider">NET OPEN</Badge>}
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
On air: <strong className="text-foreground">{active.length}</strong> ·
|
||||
Roster: <strong className="text-foreground">{roster.length}</strong>
|
||||
</span>
|
||||
{error && <Badge variant="destructive" className="max-w-[280px] truncate" title={error}>{error}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* ACTIVE USERS (left) */}
|
||||
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-600 text-white text-[11px] font-semibold uppercase tracking-wider">
|
||||
On air — active QSOs
|
||||
<span className="ml-auto font-normal normal-case opacity-90">double-click → edit all fields · "Log & end" to save</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<AgGridReact<QSOForm>
|
||||
ref={activeGrid}
|
||||
theme={hamlogTheme}
|
||||
rowData={active}
|
||||
columnDefs={activeCols}
|
||||
defaultColDef={defaultColDef}
|
||||
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
||||
animateRows={false}
|
||||
getRowId={(p) => String((p.data as any).id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && active.length > 0 && (
|
||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
|
||||
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
|
||||
<MinusCircle className="size-3.5" /> Log & end selected
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* NET USERS / roster (right) */}
|
||||
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-700 text-white text-[11px] font-semibold uppercase tracking-wider">
|
||||
NET users — roster
|
||||
<span className="ml-auto font-normal normal-case opacity-90">double-click → put on air</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<AgGridReact<Station>
|
||||
ref={rosterGrid}
|
||||
theme={hamlogTheme}
|
||||
rowData={rosterShown}
|
||||
columnDefs={rosterCols}
|
||||
defaultColDef={defaultColDef}
|
||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
||||
animateRows={false}
|
||||
getRowId={(p) => String((p.data as any).callsign)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
|
||||
<UserPlus className="size-3.5" /> Add contact
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-rose-700" disabled={!selId} onClick={removeSelectedRoster}>
|
||||
<MinusCircle className="size-3.5" /> Remove
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto"
|
||||
onClick={() => { const r = rosterGrid.current?.api?.getSelectedRows?.()?.[0] as Station; if (r) activate(r.callsign); }}>
|
||||
<PlusCircle className="size-3.5" /> Put selected on air
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full-QSO edit modal for the selected on-air draft. Save writes back to
|
||||
the in-memory draft (NetUpdateActive); Delete cancels it (no log). */}
|
||||
{editingDraft && (
|
||||
<QSOEditModal
|
||||
qso={editingDraft}
|
||||
onSave={saveDraft}
|
||||
onDelete={discardDraft}
|
||||
onClose={() => setEditingDraft(null)}
|
||||
countries={countries}
|
||||
bands={bands}
|
||||
modes={modes}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add / edit contact dialog */}
|
||||
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add contact to NET</DialogTitle>
|
||||
<DialogDescription>Saved in this NET's roster (reused next time you open it).</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 px-5 py-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-[11px]">Callsign</Label>
|
||||
<Input className="font-mono uppercase" value={contact.callsign ?? ''}
|
||||
onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={lookupContact} disabled={looking || !(contact.callsign ?? '').trim()}>
|
||||
<Search className="size-3.5" /> {looking ? '…' : 'Search'}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">Name</Label>
|
||||
<Input value={contact.name ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">QTH</Label>
|
||||
<Input value={contact.qth ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">Country</Label>
|
||||
<Input value={contact.country ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={() => setContactOpen(false)}>Cancel</Button>
|
||||
<Button size="sm" onClick={saveContact} disabled={!(contact.callsign ?? '').trim()}>
|
||||
<PlusCircle className="size-3.5" /> Save in NET
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -283,7 +283,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
|
||||
<Select value={service} onValueChange={setService}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{[...SERVICES].sort((a, b) => a.label.localeCompare(b.label)).map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{uploadCall && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine } from 'lucide-react';
|
||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
||||
|
||||
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||
|
||||
@@ -15,6 +15,7 @@ type Props = {
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportFiltered?: () => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
};
|
||||
|
||||
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
||||
@@ -31,7 +32,7 @@ const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||
// which used to dismiss the menu the instant it appeared.)
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered }: Props) {
|
||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete }: Props) {
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const close = () => onClose();
|
||||
@@ -159,6 +160,19 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-rose-700 hover:bg-rose-50"
|
||||
onClick={() => { onDelete(menu.ids); onClose(); }}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span>Delete {n} QSO{n > 1 ? 's' : ''}…</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ type Props = {
|
||||
onBulkEdit?: (ids: number[]) => void;
|
||||
onExportSelected?: (ids: number[]) => void;
|
||||
onExportFiltered?: () => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
// One column per defined award; the cell shows the reference this QSO counts
|
||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||
awardCols?: { code: string; name: string }[];
|
||||
@@ -222,7 +223,7 @@ export const GROUP_ORDER = [
|
||||
'Contest', 'Propagation', 'My station', 'Misc',
|
||||
];
|
||||
|
||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, awardCols }: Props) {
|
||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete, awardCols }: Props) {
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
@@ -395,6 +396,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
onBulkEdit={onBulkEdit}
|
||||
onExportSelected={onExportSelected}
|
||||
onExportFiltered={onExportFiltered}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
|
||||
@@ -53,6 +53,7 @@ type Props = {
|
||||
onSendTo?: (service: string, ids: number[]) => void;
|
||||
onSendRecording?: (ids: number[]) => void;
|
||||
onSendEQSL?: (ids: number[]) => void;
|
||||
onDelete?: (ids: number[]) => void;
|
||||
// One column per defined award (cell = the reference this QSO counts for).
|
||||
awardCols?: { code: string; name: string }[];
|
||||
};
|
||||
@@ -67,7 +68,7 @@ function fmtDate(s: any): string {
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, awardCols }: Props) {
|
||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||
@@ -253,6 +254,7 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
||||
onSendTo={onSendTo}
|
||||
onSendRecording={onSendRecording}
|
||||
onSendEQSL={onSendEQSL}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
{count > entries.length && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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).
|
||||
export const APP_VERSION = '0.13';
|
||||
export const APP_VERSION = '0.14';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+37
@@ -16,6 +16,7 @@ import {audio} from '../models';
|
||||
import {operating} from '../models';
|
||||
import {udp} from '../models';
|
||||
import {lookup} from '../models';
|
||||
import {netctl} from '../models';
|
||||
|
||||
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
||||
|
||||
@@ -371,6 +372,40 @@ export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||
|
||||
export function MoveDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function NetActivate(arg1:string):Promise<qso.QSO>;
|
||||
|
||||
export function NetActiveList():Promise<Array<qso.QSO>>;
|
||||
|
||||
export function NetClose():Promise<void>;
|
||||
|
||||
export function NetCreate(arg1:string):Promise<netctl.Net>;
|
||||
|
||||
export function NetDeactivate(arg1:number):Promise<number>;
|
||||
|
||||
export function NetDelete(arg1:string):Promise<void>;
|
||||
|
||||
export function NetDiscardActive(arg1:number):Promise<void>;
|
||||
|
||||
export function NetList():Promise<Array<netctl.Net>>;
|
||||
|
||||
export function NetLookup(arg1:string):Promise<netctl.Station>;
|
||||
|
||||
export function NetOpen(arg1:string):Promise<void>;
|
||||
|
||||
export function NetOpenID():Promise<string>;
|
||||
|
||||
export function NetRename(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function NetRoster(arg1:string):Promise<Array<netctl.Station>>;
|
||||
|
||||
export function NetRosterRemove(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function NetRosterUpsert(arg1:string,arg2:netctl.Station):Promise<void>;
|
||||
|
||||
export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
|
||||
|
||||
export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
|
||||
|
||||
export function OpenADIFFile():Promise<string>;
|
||||
|
||||
export function OpenDatabase(arg1:string):Promise<void>;
|
||||
@@ -429,6 +464,8 @@ export function QSOAudioBegin():Promise<boolean>;
|
||||
|
||||
export function QSOAudioCancel():Promise<void>;
|
||||
|
||||
export function QSOAudioResetClock():Promise<boolean>;
|
||||
|
||||
export function QSOAudioRestart():Promise<boolean>;
|
||||
|
||||
export function QuitApp():Promise<void>;
|
||||
|
||||
@@ -710,6 +710,74 @@ export function MoveDatabase(arg1) {
|
||||
return window['go']['main']['App']['MoveDatabase'](arg1);
|
||||
}
|
||||
|
||||
export function NetActivate(arg1) {
|
||||
return window['go']['main']['App']['NetActivate'](arg1);
|
||||
}
|
||||
|
||||
export function NetActiveList() {
|
||||
return window['go']['main']['App']['NetActiveList']();
|
||||
}
|
||||
|
||||
export function NetClose() {
|
||||
return window['go']['main']['App']['NetClose']();
|
||||
}
|
||||
|
||||
export function NetCreate(arg1) {
|
||||
return window['go']['main']['App']['NetCreate'](arg1);
|
||||
}
|
||||
|
||||
export function NetDeactivate(arg1) {
|
||||
return window['go']['main']['App']['NetDeactivate'](arg1);
|
||||
}
|
||||
|
||||
export function NetDelete(arg1) {
|
||||
return window['go']['main']['App']['NetDelete'](arg1);
|
||||
}
|
||||
|
||||
export function NetDiscardActive(arg1) {
|
||||
return window['go']['main']['App']['NetDiscardActive'](arg1);
|
||||
}
|
||||
|
||||
export function NetList() {
|
||||
return window['go']['main']['App']['NetList']();
|
||||
}
|
||||
|
||||
export function NetLookup(arg1) {
|
||||
return window['go']['main']['App']['NetLookup'](arg1);
|
||||
}
|
||||
|
||||
export function NetOpen(arg1) {
|
||||
return window['go']['main']['App']['NetOpen'](arg1);
|
||||
}
|
||||
|
||||
export function NetOpenID() {
|
||||
return window['go']['main']['App']['NetOpenID']();
|
||||
}
|
||||
|
||||
export function NetRename(arg1, arg2) {
|
||||
return window['go']['main']['App']['NetRename'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function NetRoster(arg1) {
|
||||
return window['go']['main']['App']['NetRoster'](arg1);
|
||||
}
|
||||
|
||||
export function NetRosterRemove(arg1, arg2) {
|
||||
return window['go']['main']['App']['NetRosterRemove'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function NetRosterUpsert(arg1, arg2) {
|
||||
return window['go']['main']['App']['NetRosterUpsert'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function NetSetDefaults(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['NetSetDefaults'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function NetUpdateActive(arg1) {
|
||||
return window['go']['main']['App']['NetUpdateActive'](arg1);
|
||||
}
|
||||
|
||||
export function OpenADIFFile() {
|
||||
return window['go']['main']['App']['OpenADIFFile']();
|
||||
}
|
||||
@@ -826,6 +894,10 @@ export function QSOAudioCancel() {
|
||||
return window['go']['main']['App']['QSOAudioCancel']();
|
||||
}
|
||||
|
||||
export function QSOAudioResetClock() {
|
||||
return window['go']['main']['App']['QSOAudioResetClock']();
|
||||
}
|
||||
|
||||
export function QSOAudioRestart() {
|
||||
return window['go']['main']['App']['QSOAudioRestart']();
|
||||
}
|
||||
|
||||
@@ -2007,6 +2007,81 @@ export namespace main {
|
||||
|
||||
}
|
||||
|
||||
export namespace netctl {
|
||||
|
||||
export class Station {
|
||||
callsign: string;
|
||||
name?: string;
|
||||
qth?: string;
|
||||
country?: string;
|
||||
dxcc?: number;
|
||||
itu?: number;
|
||||
cq?: number;
|
||||
groups?: string;
|
||||
sig?: string;
|
||||
sig_info?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Station(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.callsign = source["callsign"];
|
||||
this.name = source["name"];
|
||||
this.qth = source["qth"];
|
||||
this.country = source["country"];
|
||||
this.dxcc = source["dxcc"];
|
||||
this.itu = source["itu"];
|
||||
this.cq = source["cq"];
|
||||
this.groups = source["groups"];
|
||||
this.sig = source["sig"];
|
||||
this.sig_info = source["sig_info"];
|
||||
}
|
||||
}
|
||||
export class Net {
|
||||
id: string;
|
||||
name: string;
|
||||
default_rst_sent?: string;
|
||||
default_rst_rcvd?: string;
|
||||
default_comment?: string;
|
||||
stations?: Station[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Net(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.default_rst_sent = source["default_rst_sent"];
|
||||
this.default_rst_rcvd = source["default_rst_rcvd"];
|
||||
this.default_comment = source["default_comment"];
|
||||
this.stations = this.convertValues(source["stations"], Station);
|
||||
}
|
||||
|
||||
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 namespace operating {
|
||||
|
||||
export class AntennaBand {
|
||||
|
||||
@@ -116,7 +116,11 @@ func (c *Client) Activate(port, antenna int) error {
|
||||
if antenna < 0 {
|
||||
return fmt.Errorf("antgenius: invalid antenna %d", antenna)
|
||||
}
|
||||
if err := c.send(fmt.Sprintf("port set %d rxant=%d txant=%d", port, antenna, antenna)); err != nil {
|
||||
// Set only rxant (like the reference ShackMaster client): the AG mirrors it
|
||||
// to the TX antenna automatically. Forcing txant too can be rejected on the
|
||||
// 8x2 (an antenna can't be TX on both ports at once), which broke port-B
|
||||
// selection and deselection.
|
||||
if err := c.send(fmt.Sprintf("port set %d rxant=%d", port, antenna)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Ask for the new port state so the snapshot reflects it promptly (the
|
||||
|
||||
@@ -11,14 +11,16 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
path string
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
path string
|
||||
crashFile *os.File // kept open so the runtime can write a crash traceback to it
|
||||
)
|
||||
|
||||
// Init opens (creates) the log file in dataDir. On rotation we truncate
|
||||
@@ -57,6 +59,17 @@ func Init(dataDir string) (string, error) {
|
||||
file = f
|
||||
path = logPath
|
||||
|
||||
// Capture a full traceback on a FATAL crash (a Go panic that escapes our
|
||||
// recover()s, or a runtime-fatal error like a concurrent map write, or a
|
||||
// Windows access violation routed through the Go signal handler) into a
|
||||
// dedicated file the runtime writes directly — so otherwise-silent process
|
||||
// deaths leave a stack we can read.
|
||||
if cf, cerr := os.OpenFile(filepath.Join(dataDir, "opslog-crash.log"),
|
||||
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); cerr == nil {
|
||||
crashFile = cf
|
||||
_ = debug.SetCrashOutput(cf, debug.CrashOptions{})
|
||||
}
|
||||
|
||||
// Redirect log.Print* and the standard logger to the file too, so
|
||||
// any third-party output stays consistent.
|
||||
log.SetOutput(io.MultiWriter(file, os.Stderr))
|
||||
|
||||
@@ -32,6 +32,20 @@ func writeMP3(path string, pcm []byte) error {
|
||||
for i, v := range mono32 {
|
||||
stereo[2*i], stereo[2*i+1] = v, v
|
||||
}
|
||||
// Shine's Write() reads a WHOLE frame (samplesPerPass × channels = 1152 × 2 =
|
||||
// 2304 interleaved samples for MPEG-1) per pass via unsafe pointer arithmetic,
|
||||
// regardless of how short the trailing chunk is. If the buffer isn't an exact
|
||||
// multiple of a frame, the final pass reads past the slice and the process
|
||||
// dies with an access violation (0xc0000005) inside windowFilterSubband.
|
||||
// Pad with trailing silence to a whole number of frames so no partial pass
|
||||
// exists. (~36 ms of silence at most — inaudible.)
|
||||
const frameInterleaved = 1152 * 2 // samplesPerPass(MPEG-1) × 2 channels
|
||||
if rem := len(stereo) % frameInterleaved; rem != 0 {
|
||||
stereo = append(stereo, make([]int16, frameInterleaved-rem)...)
|
||||
}
|
||||
if len(stereo) == 0 {
|
||||
return nil // nothing to encode (empty recording)
|
||||
}
|
||||
enc := mp3.NewEncoder(mp3Rate, 2)
|
||||
return enc.Write(f, stereo)
|
||||
}
|
||||
|
||||
@@ -239,6 +239,22 @@ func (r *Recorder) RestartQSO() {
|
||||
r.active = true
|
||||
}
|
||||
|
||||
// ResetQSOClock restarts the active accumulation from ZERO — discarding
|
||||
// everything captured so far INCLUDING the pre-roll. Unlike RestartQSO (which
|
||||
// re-seeds from the pre-roll ring), this keeps nothing: the saved file will
|
||||
// contain only audio from this moment onward. Used when the contact you entered
|
||||
// was already in a long QSO and you want to record just your own exchange.
|
||||
// No-op if not running; if no take is active it begins one (empty).
|
||||
func (r *Recorder) ResetQSOClock() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.running {
|
||||
return
|
||||
}
|
||||
r.acc = nil
|
||||
r.active = true
|
||||
}
|
||||
|
||||
// TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and
|
||||
// stops accumulating — fast, no encoding. The next BeginQSO can safely start a
|
||||
// new take immediately. Pair with WritePCM to encode/write off the hot path so
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// Package netctl persists "NET" definitions and their station rosters for the
|
||||
// NET Control feature (managing a directed net / round-table on a frequency).
|
||||
//
|
||||
// A NET is a named net (e.g. "French QSO", "QSO des Brasses") with a roster of
|
||||
// stations that habitually check in. The roster grows over time as you add new
|
||||
// callsigns. Storage is a single JSON file in the data dir — global/shared
|
||||
// across all logbooks (a net like "French QSO" is reused whatever logbook is
|
||||
// open). The QSOs themselves are logged into the active logbook by the caller;
|
||||
// this package only owns the net definitions + rosters, not the live session.
|
||||
package netctl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Station is one roster entry: a station registered in a net.
|
||||
type Station struct {
|
||||
Callsign string `json:"callsign"`
|
||||
Name string `json:"name,omitempty"`
|
||||
QTH string `json:"qth,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
DXCC int `json:"dxcc,omitempty"`
|
||||
ITU int `json:"itu,omitempty"`
|
||||
CQ int `json:"cq,omitempty"`
|
||||
Groups string `json:"groups,omitempty"`
|
||||
SIG string `json:"sig,omitempty"`
|
||||
SIGInfo string `json:"sig_info,omitempty"`
|
||||
}
|
||||
|
||||
// Net is a named net with default report values and a station roster.
|
||||
type Net struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DefaultRSTSent string `json:"default_rst_sent,omitempty"`
|
||||
DefaultRSTRcvd string `json:"default_rst_rcvd,omitempty"`
|
||||
DefaultComment string `json:"default_comment,omitempty"`
|
||||
Stations []Station `json:"stations,omitempty"`
|
||||
}
|
||||
|
||||
// Store is the persistent collection of nets, backed by a JSON file.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
nets []Net
|
||||
}
|
||||
|
||||
// Open loads the store from path (creating an empty one if the file is absent).
|
||||
func Open(path string) (*Store, error) {
|
||||
s := &Store{path: path}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return s, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(b) > 0 {
|
||||
if err := json.Unmarshal(b, &s.nets); err != nil {
|
||||
// Corrupt file: start empty rather than failing the whole app.
|
||||
s.nets = nil
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// save writes the current state to disk. Caller must hold s.mu.
|
||||
func (s *Store) save() error {
|
||||
b, err := json.MarshalIndent(s.nets, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) }
|
||||
|
||||
// List returns a copy of all nets (with rosters), ordered by name.
|
||||
func (s *Store) List() []Net {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]Net, len(s.nets))
|
||||
copy(out, s.nets)
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// find returns the index of the net with id, or -1. Caller must hold s.mu.
|
||||
func (s *Store) find(id string) int {
|
||||
for i := range s.nets {
|
||||
if s.nets[i].ID == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Create adds a new net with default reports of 59/59 and returns it.
|
||||
func (s *Store) Create(name string) (Net, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return Net{}, fmt.Errorf("net name required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.nets {
|
||||
if strings.EqualFold(s.nets[i].Name, name) {
|
||||
return Net{}, fmt.Errorf("a net named %q already exists", name)
|
||||
}
|
||||
}
|
||||
n := Net{ID: newID(), Name: name, DefaultRSTSent: "59", DefaultRSTRcvd: "59"}
|
||||
s.nets = append(s.nets, n)
|
||||
return n, s.save()
|
||||
}
|
||||
|
||||
// Rename changes a net's name.
|
||||
func (s *Store) Rename(id, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("net name required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
i := s.find(id)
|
||||
if i < 0 {
|
||||
return fmt.Errorf("net not found")
|
||||
}
|
||||
s.nets[i].Name = name
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// SetDefaults updates the per-net default report/comment values.
|
||||
func (s *Store) SetDefaults(id, rstSent, rstRcvd, comment string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
i := s.find(id)
|
||||
if i < 0 {
|
||||
return fmt.Errorf("net not found")
|
||||
}
|
||||
s.nets[i].DefaultRSTSent = rstSent
|
||||
s.nets[i].DefaultRSTRcvd = rstRcvd
|
||||
s.nets[i].DefaultComment = comment
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// Delete removes a net and its roster.
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
i := s.find(id)
|
||||
if i < 0 {
|
||||
return fmt.Errorf("net not found")
|
||||
}
|
||||
s.nets = append(s.nets[:i], s.nets[i+1:]...)
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// Get returns a copy of one net by id.
|
||||
func (s *Store) Get(id string) (Net, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
i := s.find(id)
|
||||
if i < 0 {
|
||||
return Net{}, false
|
||||
}
|
||||
return s.nets[i], true
|
||||
}
|
||||
|
||||
// Roster returns a net's stations, sorted by callsign.
|
||||
func (s *Store) Roster(id string) ([]Station, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
i := s.find(id)
|
||||
if i < 0 {
|
||||
return nil, fmt.Errorf("net not found")
|
||||
}
|
||||
out := make([]Station, len(s.nets[i].Stations))
|
||||
copy(out, s.nets[i].Stations)
|
||||
sort.Slice(out, func(a, b int) bool { return out[a].Callsign < out[b].Callsign })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RosterUpsert adds st to the net's roster, or updates it if the callsign is
|
||||
// already present (matched case-insensitively; the callsign is stored upper).
|
||||
func (s *Store) RosterUpsert(id string, st Station) error {
|
||||
st.Callsign = strings.ToUpper(strings.TrimSpace(st.Callsign))
|
||||
if st.Callsign == "" {
|
||||
return fmt.Errorf("callsign required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
i := s.find(id)
|
||||
if i < 0 {
|
||||
return fmt.Errorf("net not found")
|
||||
}
|
||||
for j := range s.nets[i].Stations {
|
||||
if strings.EqualFold(s.nets[i].Stations[j].Callsign, st.Callsign) {
|
||||
s.nets[i].Stations[j] = st
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
s.nets[i].Stations = append(s.nets[i].Stations, st)
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// RosterRemove deletes a callsign from a net's roster.
|
||||
func (s *Store) RosterRemove(id, callsign string) error {
|
||||
callsign = strings.ToUpper(strings.TrimSpace(callsign))
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
i := s.find(id)
|
||||
if i < 0 {
|
||||
return fmt.Errorf("net not found")
|
||||
}
|
||||
for j := range s.nets[i].Stations {
|
||||
if strings.EqualFold(s.nets[i].Stations[j].Callsign, callsign) {
|
||||
s.nets[i].Stations = append(s.nets[i].Stations[:j], s.nets[i].Stations[j+1:]...)
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return nil // not present → nothing to do
|
||||
}
|
||||
@@ -45,6 +45,10 @@ type Client struct {
|
||||
|
||||
statusMu sync.RWMutex
|
||||
status Status
|
||||
// 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.
|
||||
fanPending string
|
||||
fanPendingAt time.Time
|
||||
|
||||
cmdID atomic.Int64
|
||||
stop chan struct{}
|
||||
@@ -102,7 +106,10 @@ func (c *Client) SetFanMode(mode string) error {
|
||||
if _, err := c.command("setup fanmode=" + m); err != nil {
|
||||
return err
|
||||
}
|
||||
c.setStatus(func(s *Status) { s.FanMode = m }) // optimistic
|
||||
c.statusMu.Lock()
|
||||
c.status.FanMode = m // optimistic
|
||||
c.fanPending, c.fanPendingAt = m, time.Now()
|
||||
c.statusMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -217,7 +224,13 @@ func (c *Client) parse(resp string) {
|
||||
case "state":
|
||||
c.status.State = kv[1]
|
||||
case "fanmode":
|
||||
c.status.FanMode = strings.ToUpper(kv[1])
|
||||
dev := strings.ToUpper(kv[1])
|
||||
// Honour a recent optimistic change until the amp confirms it.
|
||||
if c.fanPending != "" && time.Since(c.fanPendingAt) < 3*time.Second && dev != c.fanPending {
|
||||
break
|
||||
}
|
||||
c.fanPending = ""
|
||||
c.status.FanMode = dev
|
||||
case "temp":
|
||||
c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64)
|
||||
}
|
||||
|
||||
+5
-2
@@ -1621,8 +1621,9 @@ func scanAwardQSO(s scanner) (QSO, error) {
|
||||
// NEW / NEW SLOT / WORKED in constant time after one batched query.
|
||||
type EntitySlot struct {
|
||||
Country string
|
||||
Bands map[string]struct{} // bands worked, any mode
|
||||
Slots map[string]map[string]struct{} // band → modes worked
|
||||
Bands map[string]struct{} // bands worked, any mode
|
||||
Modes map[string]struct{} // modes worked, any band
|
||||
Slots map[string]map[string]struct{} // band → modes worked
|
||||
}
|
||||
|
||||
// EntitySlotMap returns slot data for every QSO, grouped by DXCC entity NUMBER.
|
||||
@@ -1667,11 +1668,13 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store
|
||||
e = &EntitySlot{
|
||||
Country: country,
|
||||
Bands: make(map[string]struct{}),
|
||||
Modes: make(map[string]struct{}),
|
||||
Slots: make(map[string]map[string]struct{}),
|
||||
}
|
||||
out[key] = e
|
||||
}
|
||||
e.Bands[band] = struct{}{}
|
||||
e.Modes[mode] = struct{}{}
|
||||
bandSlots, ok := e.Slots[band]
|
||||
if !ok {
|
||||
bandSlots = make(map[string]struct{})
|
||||
|
||||
@@ -68,6 +68,13 @@ type Client struct {
|
||||
running bool
|
||||
seqNum byte
|
||||
seqMu sync.Mutex
|
||||
|
||||
// Optimistic pattern direction kept until the antenna's status poll reports
|
||||
// it (or it ages out) — the motors take a second or two, and a stale poll in
|
||||
// between would otherwise snap the UI back to the old direction.
|
||||
pendingDir int
|
||||
pendingDirAt time.Time
|
||||
pendingDirSet bool
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
@@ -199,6 +206,14 @@ func (c *Client) pollLoop() {
|
||||
}
|
||||
|
||||
c.statusMu.Lock()
|
||||
// Keep a just-commanded direction until the antenna reports it.
|
||||
if c.pendingDirSet {
|
||||
if time.Since(c.pendingDirAt) > 4*time.Second || status.Direction == c.pendingDir {
|
||||
c.pendingDirSet = false
|
||||
} else {
|
||||
status.Direction = c.pendingDir
|
||||
}
|
||||
}
|
||||
c.lastStatus = status
|
||||
c.statusMu.Unlock()
|
||||
|
||||
@@ -449,6 +464,14 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||
}
|
||||
|
||||
_, err := c.sendCommand(CMD_FREQ, data)
|
||||
if err == nil {
|
||||
c.statusMu.Lock()
|
||||
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
||||
if c.lastStatus != nil {
|
||||
c.lastStatus.Direction = direction // reflect immediately
|
||||
}
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.13"
|
||||
appVersion = "0.14"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user